diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go index 83c7d0ce9f..b225b90a69 100644 --- a/github/resource_github_organization_ruleset.go +++ b/github/resource_github_organization_ruleset.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "net/http" + "regexp" "strconv" + "strings" "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -14,6 +16,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) +var supportedOrgRulesetTargetTypes = []string{string(github.RulesetTargetBranch), string(github.RulesetTargetTag), string(github.RulesetTargetPush)} + func resourceGithubOrganizationRuleset() *schema.Resource { return &schema.Resource{ CreateContext: resourceGithubOrganizationRulesetCreate, @@ -26,24 +30,27 @@ func resourceGithubOrganizationRuleset() *schema.Resource { SchemaVersion: 1, + CustomizeDiff: resourceGithubOrganizationRulesetDiff, + Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringLenBetween(1, 100), - Description: "The name of the ruleset.", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 100)), + Description: "The name of the ruleset.", }, "target": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"branch", "tag", "push"}, false), - Description: "Possible values are `branch`, `tag` and `push`. Note: The `push` target is in beta and is subject to change.", + Type: schema.TypeString, + Required: true, + // The API accepts an `repository` target, but we don't support it yet. + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(supportedOrgRulesetTargetTypes, false)), + Description: "The target of the ruleset. Possible values are " + strings.Join(supportedOrgRulesetTargetTypes[:len(supportedOrgRulesetTargetTypes)-1], ", ") + " and " + supportedOrgRulesetTargetTypes[len(supportedOrgRulesetTargetTypes)-1] + ".", }, "enforcement": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"disabled", "active", "evaluate"}, false), - Description: "Possible values for Enforcement are `disabled`, `active`, `evaluate`. Note: `evaluate` is currently only supported for owners of type `organization`.", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"disabled", "active", "evaluate"}, false)), + Description: "The enforcement level of the ruleset. `evaluate` allows admins to test rules before enforcing them. Possible values are `disabled`, `active`, and `evaluate`. Note: `evaluate` is only available for Enterprise plans.", }, "bypass_actors": { Type: schema.TypeList, // TODO: These are returned from GH API sorted by actor_id, we might want to investigate if we want to include sorting @@ -59,16 +66,16 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "The ID of the actor that can bypass a ruleset. When `actor_type` is `OrganizationAdmin`, this should be set to `1`. Some resources such as DeployKey do not have an ID and this should be omitted.", }, "actor_type": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey"}, false), - Description: "The type of actor that can bypass a ruleset. See https://docs.github.com/en/rest/orgs/rules for more information", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey"}, false)), + Description: "The type of actor that can bypass a ruleset. Can be one of: `Integration`, `OrganizationAdmin`, `RepositoryRole`, `Team`, or `DeployKey`.", }, "bypass_mode": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice([]string{"always", "pull_request", "exempt"}, false), - Description: "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`.", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"always", "pull_request", "exempt"}, false)), + Description: "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`.", }, }, }, @@ -87,13 +94,14 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Type: schema.TypeList, Optional: true, MaxItems: 1, - Description: "Parameters for an organization ruleset condition. `ref_name` is required alongside one of `repository_name` or `repository_id`.", + Description: "Parameters for an organization ruleset condition. `ref_name` is required for `branch` and `tag` targets, but must not be set for `push` targets. One of `repository_name` or `repository_id` is always required.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "ref_name": { - Type: schema.TypeList, - Required: true, - MaxItems: 1, + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Targets refs that match the specified patterns. Required for `branch` and `tag` targets.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "include": { @@ -119,6 +127,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Type: schema.TypeList, Optional: true, MaxItems: 1, + Description: "Targets repositories that match the specified name patterns.", ExactlyOneOf: []string{"conditions.0.repository_id"}, AtLeastOneOf: []string{"conditions.0.repository_id"}, Elem: &schema.Resource{ @@ -206,7 +215,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", Elem: &schema.Schema{ Type: schema.TypeString, - ValidateDiagFunc: toDiagFunc(validation.StringInSlice([]string{"merge", "squash", "rebase"}, false), "allowed_merge_methods"), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"merge", "squash", "rebase"}, false)), }, }, "dismiss_stale_reviews_on_push": { @@ -228,10 +237,11 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "Whether the most recent reviewable push must be approved by someone other than the person who pushed it. Defaults to `false`.", }, "required_approving_review_count": { - Type: schema.TypeInt, - Optional: true, - Default: 0, - Description: "The number of approving reviews that are required before a pull request can be merged. Defaults to `0`.", + Type: schema.TypeInt, + Optional: true, + Default: 0, + ValidateDiagFunc: validation.ToDiagFunc(validation.IntBetween(0, 10)), + Description: "The number of approving reviews that are required before a pull request can be merged. Defaults to `0`.", }, "required_review_thread_resolution": { Type: schema.TypeBool, @@ -279,9 +289,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "context": { - Type: schema.TypeString, - Required: true, - Description: "The status check context name that must be present on the commit.", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "The status check context name that must be present on the commit.", }, "integration_id": { Type: schema.TypeInt, @@ -309,7 +320,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { "non_fast_forward": { Type: schema.TypeBool, Optional: true, - Description: "Prevent users with push access from force pushing to branches.", + Description: "Prevent users with push access from force pushing to refs.", }, "commit_message_pattern": { Type: schema.TypeList, @@ -329,9 +340,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "If true, the rule will fail if the pattern matches.", }, "operator": { - Type: schema.TypeString, - Required: true, - Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + Type: schema.TypeString, + ValidateDiagFunc: operatorValidation, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", }, "pattern": { Type: schema.TypeString, @@ -359,9 +371,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "If true, the rule will fail if the pattern matches.", }, "operator": { - Type: schema.TypeString, - Required: true, - Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + Type: schema.TypeString, + ValidateDiagFunc: operatorValidation, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", }, "pattern": { Type: schema.TypeString, @@ -389,9 +402,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "If true, the rule will fail if the pattern matches.", }, "operator": { - Type: schema.TypeString, - Required: true, - Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + Type: schema.TypeString, + ValidateDiagFunc: operatorValidation, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", }, "pattern": { Type: schema.TypeString, @@ -420,9 +434,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "If true, the rule will fail if the pattern matches.", }, "operator": { - Type: schema.TypeString, - Required: true, - Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + Type: schema.TypeString, + ValidateDiagFunc: operatorValidation, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", }, "pattern": { Type: schema.TypeString, @@ -451,9 +466,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "If true, the rule will fail if the pattern matches.", }, "operator": { - Type: schema.TypeString, - Required: true, - Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + Type: schema.TypeString, + ValidateDiagFunc: operatorValidation, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", }, "pattern": { Type: schema.TypeString, @@ -488,9 +504,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Description: "The repository in which the workflow is defined.", }, "path": { - Type: schema.TypeString, - Required: true, - Description: "The path to the workflow YAML definition file.", + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringMatch(regexp.MustCompile(`^\.github\/workflows\/.*$`), "Path must be in the .github/workflows directory")), + Description: "The path to the workflow YAML definition file.", }, "ref": { Type: schema.TypeString, @@ -519,14 +536,16 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "alerts_threshold": { - Type: schema.TypeString, - Required: true, - Description: "The severity level at which code scanning results that raise alerts block a reference update. Can be one of: `none`, `errors`, `errors_and_warnings`, `all`.", + Description: "The severity level at which code scanning results that raise alerts block a reference update. Can be one of: `none`, `errors`, `errors_and_warnings`, `all`.", + Required: true, + Type: schema.TypeString, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"none", "errors", "errors_and_warnings", "all"}, false)), }, "security_alerts_threshold": { - Type: schema.TypeString, - Required: true, - Description: "The severity level at which code scanning results that raise security alerts block a reference update. Can be one of: `none`, `critical`, `high_or_higher`, `medium_or_higher`, `all`.", + Description: "The severity level at which code scanning results that raise security alerts block a reference update. Can be one of: `none`, `critical`, `high_or_higher`, `medium_or_higher`, `all`.", + Required: true, + Type: schema.TypeString, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"none", "critical", "high_or_higher", "medium_or_higher", "all"}, false)), }, "tool": { Type: schema.TypeString, @@ -569,7 +588,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Type: schema.TypeInt, Required: true, Description: "The maximum allowed size of a file in megabytes (MB). Valid range is 1-100 MB.", - ValidateDiagFunc: toDiagFunc(validation.IntBetween(1, 100), "max_file_size"), + ValidateDiagFunc: validation.ToDiagFunc(validation.IntBetween(1, 100)), }, }, }, @@ -582,9 +601,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "max_file_path_length": { - Type: schema.TypeInt, - Required: true, - Description: "The maximum allowed length of a file path.", + Type: schema.TypeInt, + Required: true, + Description: "The maximum allowed length of a file path.", + ValidateDiagFunc: validation.ToDiagFunc(validation.IntBetween(1, 32767)), }, }, }, @@ -612,8 +632,9 @@ func resourceGithubOrganizationRuleset() *schema.Resource { }, }, "etag": { - Type: schema.TypeString, - Computed: true, + Type: schema.TypeString, + Computed: true, + Description: "An etag representing the ruleset for caching purposes.", }, }, } @@ -641,16 +662,24 @@ func resourceGithubOrganizationRulesetCreate(ctx context.Context, d *schema.Reso return diag.FromErr(err) } - d.SetId(strconv.FormatInt(*ruleset.ID, 10)) - _ = d.Set("ruleset_id", ruleset.ID) - _ = d.Set("node_id", ruleset.GetNodeID()) - _ = d.Set("etag", resp.Header.Get("ETag")) - _ = d.Set("rules", flattenRules(ruleset.Rules, true)) + d.SetId(strconv.FormatInt(ruleset.GetID(), 10)) + if err := d.Set("ruleset_id", ruleset.ID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("node_id", ruleset.GetNodeID()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("etag", resp.Header.Get("ETag")); err != nil { + return diag.FromErr(err) + } + if err := d.Set("rules", flattenRules(ctx, ruleset.Rules, true)); err != nil { + return diag.FromErr(err) + } - tflog.Info(ctx, fmt.Sprintf("Created organization ruleset: %s/%s (ID: %d)", owner, name, *ruleset.ID), map[string]any{ + tflog.Info(ctx, fmt.Sprintf("Created organization ruleset: %s/%s (ID: %d)", owner, name, ruleset.GetID()), map[string]any{ "owner": owner, "name": name, - "ruleset_id": *ruleset.ID, + "ruleset_id": ruleset.GetID(), }) return nil @@ -707,15 +736,33 @@ func resourceGithubOrganizationRulesetRead(ctx context.Context, d *schema.Resour return diag.FromErr(err) } - _ = d.Set("ruleset_id", ruleset.ID) - _ = d.Set("name", ruleset.Name) - _ = d.Set("target", ruleset.GetTarget()) - _ = d.Set("enforcement", ruleset.Enforcement) - _ = d.Set("bypass_actors", flattenBypassActors(ruleset.BypassActors)) - _ = d.Set("conditions", flattenConditions(ruleset.GetConditions(), true)) - _ = d.Set("rules", flattenRules(ruleset.Rules, true)) - _ = d.Set("node_id", ruleset.GetNodeID()) - _ = d.Set("etag", resp.Header.Get("ETag")) + if err := d.Set("ruleset_id", ruleset.ID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("name", ruleset.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set("target", ruleset.GetTarget()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("enforcement", ruleset.Enforcement); err != nil { + return diag.FromErr(err) + } + if err := d.Set("bypass_actors", flattenBypassActors(ruleset.BypassActors)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("conditions", flattenConditions(ctx, ruleset.GetConditions(), true)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("rules", flattenRules(ctx, ruleset.Rules, true)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("node_id", ruleset.GetNodeID()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("etag", resp.Header.Get("ETag")); err != nil { + return diag.FromErr(err) + } tflog.Trace(ctx, fmt.Sprintf("Successfully read organization ruleset: %s/%d", owner, rulesetID), map[string]any{ "owner": owner, @@ -759,10 +806,16 @@ func resourceGithubOrganizationRulesetUpdate(ctx context.Context, d *schema.Reso return diag.FromErr(err) } - d.SetId(strconv.FormatInt(*ruleset.ID, 10)) - _ = d.Set("ruleset_id", ruleset.ID) - _ = d.Set("node_id", ruleset.GetNodeID()) - _ = d.Set("etag", resp.Header.Get("ETag")) + d.SetId(strconv.FormatInt(ruleset.GetID(), 10)) + if err := d.Set("ruleset_id", ruleset.ID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("node_id", ruleset.GetNodeID()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("etag", resp.Header.Get("ETag")); err != nil { + return diag.FromErr(err) + } tflog.Info(ctx, fmt.Sprintf("Updated organization ruleset: %s/%d", owner, rulesetID), map[string]any{ "owner": owner, @@ -855,3 +908,17 @@ func resourceGithubOrganizationRulesetImport(ctx context.Context, d *schema.Reso return []*schema.ResourceData{d}, nil } + +func resourceGithubOrganizationRulesetDiff(ctx context.Context, d *schema.ResourceDiff, _ any) error { + err := validateRulesetConditions(ctx, d, true) + if err != nil { + return err + } + + err = validateRulesetRules(ctx, d) + if err != nil { + return err + } + + return nil +} diff --git a/github/resource_github_organization_ruleset_test.go b/github/resource_github_organization_ruleset_test.go index fea3624c6c..fb189c7c92 100644 --- a/github/resource_github_organization_ruleset_test.go +++ b/github/resource_github_organization_ruleset_test.go @@ -2,6 +2,7 @@ package github import ( "fmt" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -18,6 +19,19 @@ func TestAccGithubOrganizationRuleset(t *testing.T) { workflowFilePath := ".github/workflows/echo.yaml" config := fmt.Sprintf(` +locals { + workflow_content = <: return func(s *terraform.State) (string, error) { diff --git a/github/util_rules.go b/github/util_rules.go index bc8ac1cdd8..ebf84e679f 100644 --- a/github/util_rules.go +++ b/github/util_rules.go @@ -1,11 +1,12 @@ package github import ( - "log" + "context" "reflect" "sort" "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -217,20 +218,23 @@ func expandConditions(input []any, org bool) *github.RepositoryRulesetConditions return rulesetConditions } -func flattenConditions(conditions *github.RepositoryRulesetConditions, org bool) []any { - if conditions == nil || conditions.RefName == nil { +func flattenConditions(ctx context.Context, conditions *github.RepositoryRulesetConditions, org bool) []any { + if conditions == nil || reflect.DeepEqual(conditions, &github.RepositoryRulesetConditions{}) { + tflog.Debug(ctx, "Conditions are empty, returning empty list") return []any{} } conditionsMap := make(map[string]any) refNameSlice := make([]map[string]any, 0) - refNameSlice = append(refNameSlice, map[string]any{ - "include": conditions.RefName.Include, - "exclude": conditions.RefName.Exclude, - }) + if conditions.RefName != nil { + refNameSlice = append(refNameSlice, map[string]any{ + "include": conditions.RefName.Include, + "exclude": conditions.RefName.Exclude, + }) - conditionsMap["ref_name"] = refNameSlice + conditionsMap["ref_name"] = refNameSlice + } // org-only fields if org { @@ -528,7 +532,7 @@ func expandRules(input []any, org bool) *github.RepositoryRulesetRules { return rulesetRules } -func flattenRules(rules *github.RepositoryRulesetRules, org bool) []any { +func flattenRules(ctx context.Context, rules *github.RepositoryRulesetRules, org bool) []any { if rules == nil { return []any{} } @@ -573,7 +577,7 @@ func flattenRules(rules *github.RepositoryRulesetRules, org bool) []any { "required_review_thread_resolution": rules.PullRequest.RequiredReviewThreadResolution, "allowed_merge_methods": rules.PullRequest.AllowedMergeMethods, }) - log.Printf("[DEBUG] Flattened Pull Request rules slice request slice: %#v", pullRequestSlice) + tflog.Debug(ctx, "Flattened Pull Request rules slice", map[string]any{"pull_request": pullRequestSlice}) rulesMap["pull_request"] = pullRequestSlice } diff --git a/github/util_rules_test.go b/github/util_rules_test.go index 8aaec0aed4..7ac73bb02c 100644 --- a/github/util_rules_test.go +++ b/github/util_rules_test.go @@ -57,7 +57,7 @@ func TestFlattenRulesBasicRules(t *testing.T) { NonFastForward: &github.EmptyRuleParameters{}, } - result := flattenRules(rules, false) + result := flattenRules(t.Context(), rules, false) if len(result) != 1 { t.Fatalf("Expected 1 element in result, got %d", len(result)) @@ -126,7 +126,7 @@ func TestFlattenRulesMaxFilePathLength(t *testing.T) { }, } - result := flattenRules(rules, false) + result := flattenRules(t.Context(), rules, false) if len(result) != 1 { t.Fatalf("Expected 1 element in result, got %d", len(result)) @@ -167,7 +167,7 @@ func TestRoundTripMaxFilePathLength(t *testing.T) { } // Flatten back to terraform format - flattenedResult := flattenRules(expandedRules, false) + flattenedResult := flattenRules(t.Context(), expandedRules, false) if len(flattenedResult) != 1 { t.Fatalf("Expected 1 flattened result, got %d", len(flattenedResult)) @@ -224,7 +224,7 @@ func TestFlattenRulesMaxFileSize(t *testing.T) { }, } - result := flattenRules(rules, false) + result := flattenRules(t.Context(), rules, false) if len(result) != 1 { t.Fatalf("Expected 1 element in result, got %d", len(result)) @@ -292,7 +292,7 @@ func TestFlattenRulesFileExtensionRestriction(t *testing.T) { }, } - result := flattenRules(rules, false) + result := flattenRules(t.Context(), rules, false) if len(result) != 1 { t.Fatalf("Expected 1 element in result, got %d", len(result)) @@ -372,7 +372,7 @@ func TestCompletePushRulesetSupport(t *testing.T) { } // Flatten back to terraform format - flattenedResult := flattenRules(expandedRules, false) + flattenedResult := flattenRules(t.Context(), expandedRules, false) if len(flattenedResult) != 1 { t.Fatalf("Expected 1 flattened result, got %d", len(flattenedResult)) @@ -452,7 +452,7 @@ func TestCopilotCodeReviewRoundTrip(t *testing.T) { } // Flatten back to terraform format - flattenedResult := flattenRules(expandedRules, false) + flattenedResult := flattenRules(t.Context(), expandedRules, false) if len(flattenedResult) != 1 { t.Fatalf("Expected 1 flattened result, got %d", len(flattenedResult)) @@ -473,3 +473,155 @@ func TestCopilotCodeReviewRoundTrip(t *testing.T) { t.Errorf("Expected review_draft_pull_requests to be false, got %v", copilotRules[0]["review_draft_pull_requests"]) } } + +func TestFlattenConditions_PushRuleset_WithRepositoryNameOnly(t *testing.T) { + // Push rulesets don't use ref_name - they only have repository_name or repository_id. + // flattenConditions should return the conditions even when RefName is nil. + conditions := &github.RepositoryRulesetConditions{ + RefName: nil, // Push rulesets don't have ref_name + RepositoryName: &github.RepositoryRulesetRepositoryNamesConditionParameters{ + Include: []string{"~ALL"}, + Exclude: []string{}, + }, + } + + result := flattenConditions(t.Context(), conditions, true) // org=true for organization rulesets + + if len(result) != 1 { + t.Fatalf("Expected 1 conditions block, got %d", len(result)) + } + + conditionsMap := result[0].(map[string]any) + + // ref_name should be empty for push rulesets + refNameSlice := conditionsMap["ref_name"] + if refNameSlice != nil { + t.Fatalf("Expected ref_name to be nil, got %T", conditionsMap["ref_name"]) + } + + // repository_name should be present + repoNameSlice, ok := conditionsMap["repository_name"].([]map[string]any) + if !ok { + t.Fatalf("Expected repository_name to be []map[string]any, got %T", conditionsMap["repository_name"]) + } + if len(repoNameSlice) != 1 { + t.Fatalf("Expected 1 repository_name block, got %d", len(repoNameSlice)) + } + + include, ok := repoNameSlice[0]["include"].([]string) + if !ok { + t.Fatalf("Expected include to be []string, got %T", repoNameSlice[0]["include"]) + } + if len(include) != 1 || include[0] != "~ALL" { + t.Errorf("Expected include to be [~ALL], got %v", include) + } +} + +func TestFlattenConditions_BranchRuleset_WithRefNameAndRepositoryName(t *testing.T) { + // Branch/tag rulesets have both ref_name and repository_name. + // This test ensures we didn't break the existing behavior. + conditions := &github.RepositoryRulesetConditions{ + RefName: &github.RepositoryRulesetRefConditionParameters{ + Include: []string{"~DEFAULT_BRANCH", "refs/heads/main"}, + Exclude: []string{"refs/heads/experimental-*"}, + }, + RepositoryName: &github.RepositoryRulesetRepositoryNamesConditionParameters{ + Include: []string{"~ALL"}, + Exclude: []string{"test-*"}, + }, + } + + result := flattenConditions(t.Context(), conditions, true) // org=true for organization rulesets + + if len(result) != 1 { + t.Fatalf("Expected 1 conditions block, got %d", len(result)) + } + + conditionsMap := result[0].(map[string]any) + + // ref_name should be present for branch/tag rulesets + refNameSlice, ok := conditionsMap["ref_name"].([]map[string]any) + if !ok { + t.Fatalf("Expected ref_name to be []map[string]any, got %T", conditionsMap["ref_name"]) + } + if len(refNameSlice) != 1 { + t.Fatalf("Expected 1 ref_name block, got %d", len(refNameSlice)) + } + + refInclude, ok := refNameSlice[0]["include"].([]string) + if !ok { + t.Fatalf("Expected ref_name include to be []string, got %T", refNameSlice[0]["include"]) + } + if len(refInclude) != 2 { + t.Errorf("Expected 2 ref_name includes, got %d", len(refInclude)) + } + + refExclude, ok := refNameSlice[0]["exclude"].([]string) + if !ok { + t.Fatalf("Expected ref_name exclude to be []string, got %T", refNameSlice[0]["exclude"]) + } + if len(refExclude) != 1 { + t.Errorf("Expected 1 ref_name exclude, got %d", len(refExclude)) + } + + // repository_name should also be present + repoNameSlice, ok := conditionsMap["repository_name"].([]map[string]any) + if !ok { + t.Fatalf("Expected repository_name to be []map[string]any, got %T", conditionsMap["repository_name"]) + } + if len(repoNameSlice) != 1 { + t.Fatalf("Expected 1 repository_name block, got %d", len(repoNameSlice)) + } + + repoInclude, ok := repoNameSlice[0]["include"].([]string) + if !ok { + t.Fatalf("Expected repository_name include to be []string, got %T", repoNameSlice[0]["include"]) + } + if len(repoInclude) != 1 || repoInclude[0] != "~ALL" { + t.Errorf("Expected repository_name include to be [~ALL], got %v", repoInclude) + } + + repoExclude, ok := repoNameSlice[0]["exclude"].([]string) + if !ok { + t.Fatalf("Expected repository_name exclude to be []string, got %T", repoNameSlice[0]["exclude"]) + } + if len(repoExclude) != 1 || repoExclude[0] != "test-*" { + t.Errorf("Expected repository_name exclude to be [test-*], got %v", repoExclude) + } +} + +func TestFlattenConditions_PushRuleset_WithRepositoryIdOnly(t *testing.T) { + // Push rulesets can also use repository_id instead of repository_name. + conditions := &github.RepositoryRulesetConditions{ + RefName: nil, // Push rulesets don't have ref_name + RepositoryID: &github.RepositoryRulesetRepositoryIDsConditionParameters{ + RepositoryIDs: []int64{12345, 67890}, + }, + } + + result := flattenConditions(t.Context(), conditions, true) // org=true for organization rulesets + + if len(result) != 1 { + t.Fatalf("Expected 1 conditions block, got %d", len(result)) + } + + conditionsMap := result[0].(map[string]any) + + // ref_name should be nil for push rulesets + refNameSlice := conditionsMap["ref_name"] + if refNameSlice != nil { + t.Fatalf("Expected ref_name to be nil, got %T", conditionsMap["ref_name"]) + } + + // repository_id should be present + repoIDs, ok := conditionsMap["repository_id"].([]int64) + if !ok { + t.Fatalf("Expected repository_id to be []int64, got %T", conditionsMap["repository_id"]) + } + if len(repoIDs) != 2 { + t.Fatalf("Expected 2 repository IDs, got %d", len(repoIDs)) + } + if repoIDs[0] != 12345 || repoIDs[1] != 67890 { + t.Errorf("Expected repository IDs [12345, 67890], got %v", repoIDs) + } +} diff --git a/github/util_ruleset_validation.go b/github/util_ruleset_validation.go new file mode 100644 index 0000000000..2ee0254fc1 --- /dev/null +++ b/github/util_ruleset_validation.go @@ -0,0 +1,194 @@ +package github + +import ( + "context" + "fmt" + "slices" + + "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +var operatorValidation = validation.ToDiagFunc(validation.StringInSlice([]string{"starts_with", "ends_with", "contains", "regex"}, false)) + +// branchTagOnlyRules contains rules that are only valid for branch and tag targets. +// +// These rules apply to ref-based operations (branches and tags) and are not supported +// for push rulesets which operate on file content. +// +// To verify/maintain this list: +// 1. Check the GitHub API documentation for organization rulesets: +// https://docs.github.com/en/rest/orgs/rules?apiVersion=2022-11-28#create-an-organization-repository-ruleset +// 2. The API docs don't clearly separate push vs branch/tag rules. To verify, +// attempt to create a push ruleset via API or UI with each rule type. +// Push rulesets will reject branch/tag rules with "Invalid rule ''" error. +// 3. Generally, push rules deal with file content (paths, sizes, extensions), +// while branch/tag rules deal with ref lifecycle and merge requirements. +var branchTagOnlyRules = []github.RepositoryRuleType{ + github.RulesetRuleTypeCreation, + github.RulesetRuleTypeUpdate, + github.RulesetRuleTypeDeletion, + github.RulesetRuleTypeRequiredLinearHistory, + github.RulesetRuleTypeRequiredSignatures, + github.RulesetRuleTypePullRequest, + github.RulesetRuleTypeRequiredStatusChecks, + github.RulesetRuleTypeNonFastForward, + github.RulesetRuleTypeCommitMessagePattern, + github.RulesetRuleTypeCommitAuthorEmailPattern, + github.RulesetRuleTypeCommitterEmailPattern, + github.RulesetRuleTypeBranchNamePattern, + github.RulesetRuleTypeTagNamePattern, + github.RulesetRuleTypeWorkflows, + github.RulesetRuleTypeCodeScanning, + github.RulesetRuleTypeRequiredDeployments, + github.RulesetRuleTypeMergeQueue, + github.RulesetRuleTypeCopilotCodeReview, +} + +// pushOnlyRules contains rules that are only valid for push targets. +// +// These rules apply to push operations and control what content can be pushed +// to repositories. They are not supported for branch or tag rulesets. +// +// To verify/maintain this list: +// 1. Check the GitHub API documentation for organization rulesets: +// https://docs.github.com/en/rest/orgs/rules?apiVersion=2022-11-28#create-an-organization-repository-ruleset +// 2. The API docs don't clearly separate push vs branch/tag rules. To verify, +// attempt to create a branch ruleset via API or UI with each rule type. +// Branch rulesets will reject push-only rules with an error. +// 3. Push rules control file content: paths, sizes, extensions, path lengths. +var pushOnlyRules = []github.RepositoryRuleType{ + github.RulesetRuleTypeFilePathRestriction, + github.RulesetRuleTypeMaxFilePathLength, + github.RulesetRuleTypeFileExtensionRestriction, + github.RulesetRuleTypeMaxFileSize, +} + +func validateRulesForTarget(ctx context.Context, d *schema.ResourceDiff) error { + target := github.RulesetTarget(d.Get("target").(string)) + tflog.Debug(ctx, "Validating rules for target", map[string]any{"target": target}) + + switch target { + case github.RulesetTargetPush: + return validateRulesForPushTarget(ctx, d) + case github.RulesetTargetBranch, github.RulesetTargetTag: + return validateRulesForBranchTagTarget(ctx, d) + } + + tflog.Debug(ctx, "Rules validation passed", map[string]any{"target": target}) + return nil +} + +func validateRulesForPushTarget(ctx context.Context, d *schema.ResourceDiff) error { + return validateRules(ctx, d, pushOnlyRules) +} + +func validateRulesForBranchTagTarget(ctx context.Context, d *schema.ResourceDiff) error { + return validateRules(ctx, d, branchTagOnlyRules) +} + +func validateRules(ctx context.Context, d *schema.ResourceDiff, allowedRules []github.RepositoryRuleType) error { + target := github.RulesetTarget(d.Get("target").(string)) + rules := d.Get("rules").([]any)[0].(map[string]any) + for ruleName := range rules { + ruleValue, exists := d.GetOk(fmt.Sprintf("rules.0.%s", ruleName)) + if !exists { + continue + } + // These are the few rules which are not mapped to the same name in the API. + switch ruleName { + case "required_code_scanning": + ruleName = string(github.RulesetRuleTypeCodeScanning) + case "required_workflows": + ruleName = string(github.RulesetRuleTypeWorkflows) + } + switch ruleValue := ruleValue.(type) { + case []any: + if len(ruleValue) == 0 { + continue + } + case map[string]any: + if len(ruleValue) == 0 { + continue + } + case any: + if ruleValue == nil { + continue + } + } + if slices.Contains(allowedRules, github.RepositoryRuleType(ruleName)) { + continue + } else { + tflog.Debug(ctx, fmt.Sprintf("Invalid rule for %s target", target), map[string]any{"rule": ruleName, "value": ruleValue}) + return fmt.Errorf("rule %q is not valid for %[2]s target; %[2]s targets only support: %v", ruleName, target, allowedRules) + } + } + tflog.Debug(ctx, fmt.Sprintf("Rules validation passed for %s target", target)) + return nil +} + +func validateRulesetConditions(ctx context.Context, d *schema.ResourceDiff, isOrg bool) error { + target := github.RulesetTarget(d.Get("target").(string)) + tflog.Debug(ctx, "Validating conditions field based on target", map[string]any{"target": target}) + conditionsRaw := d.Get("conditions").([]any) + + if len(conditionsRaw) == 0 { + tflog.Debug(ctx, "An empty conditions block, skipping validation.", map[string]any{"target": target}) + return nil + } + + conditions := conditionsRaw[0].(map[string]any) + + switch target { + case github.RulesetTargetBranch, github.RulesetTargetTag: + return validateConditionsFieldForBranchAndTagTargets(ctx, target, conditions, isOrg) + case github.RulesetTargetPush: + return validateConditionsFieldForPushTarget(ctx, conditions) + } + return nil +} + +func validateRulesetRules(ctx context.Context, d *schema.ResourceDiff) error { + target := github.RulesetTarget(d.Get("target").(string)) + tflog.Debug(ctx, "Validating ruleset rules based on target", map[string]any{"target": target}) + + rulesRaw := d.Get("rules").([]any) + if len(rulesRaw) == 0 { + tflog.Debug(ctx, "No rules block, skipping validation") + return nil + } + + return validateRulesForTarget(ctx, d) +} + +func validateConditionsFieldForBranchAndTagTargets(ctx context.Context, target github.RulesetTarget, conditions map[string]any, isOrg bool) error { + tflog.Debug(ctx, fmt.Sprintf("Validating conditions field for %s target", target), map[string]any{"target": target, "conditions": conditions, "isOrg": isOrg}) + + if conditions["ref_name"] == nil || len(conditions["ref_name"].([]any)) == 0 { + tflog.Debug(ctx, fmt.Sprintf("Missing ref_name for %s target", target), map[string]any{"target": target}) + return fmt.Errorf("ref_name must be set for %s target", target) + } + + // Repository rulesets don't have repository_name or repository_id, only org rulesets do. + if isOrg { + if (conditions["repository_name"] == nil || len(conditions["repository_name"].([]any)) == 0) && (conditions["repository_id"] == nil || len(conditions["repository_id"].([]any)) == 0) { + tflog.Debug(ctx, fmt.Sprintf("Missing repository_name or repository_id for %s target", target), map[string]any{"target": target}) + return fmt.Errorf("either repository_name or repository_id must be set for %s target", target) + } + } + tflog.Debug(ctx, fmt.Sprintf("Conditions validation passed for %s target", target)) + return nil +} + +func validateConditionsFieldForPushTarget(ctx context.Context, conditions map[string]any) error { + tflog.Debug(ctx, "Validating conditions field for push target", map[string]any{"target": "push", "conditions": conditions}) + + if conditions["ref_name"] != nil && len(conditions["ref_name"].([]any)) > 0 { + tflog.Debug(ctx, "Invalid ref_name for push target", map[string]any{"ref_name": conditions["ref_name"]}) + return fmt.Errorf("ref_name must not be set for push target") + } + tflog.Debug(ctx, "Conditions validation passed for push target") + return nil +} diff --git a/github/util_ruleset_validation_test.go b/github/util_ruleset_validation_test.go new file mode 100644 index 0000000000..c3125fa6a5 --- /dev/null +++ b/github/util_ruleset_validation_test.go @@ -0,0 +1,227 @@ +package github + +import ( + "testing" + + "github.com/google/go-github/v82/github" +) + +func Test_validateConditionsFieldForPushTarget(t *testing.T) { + tests := []struct { + name string + conditions map[string]any + expectError bool + errorMsg string + }{ + { + name: "valid push target without ref_name", + conditions: map[string]any{ + "repository_name": []any{map[string]any{"include": []any{"~ALL"}, "exclude": []any{}}}, + }, + expectError: false, + }, + { + name: "valid push target with nil ref_name", + conditions: map[string]any{"ref_name": nil}, + expectError: false, + }, + { + name: "valid push target with empty ref_name slice", + conditions: map[string]any{"ref_name": []any{}}, + expectError: false, + }, + { + name: "invalid push target with ref_name set", + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"~ALL"}, "exclude": []any{}}}, + }, + expectError: true, + errorMsg: "ref_name must not be set for push target", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConditionsFieldForPushTarget(t.Context(), tt.conditions) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if err.Error() != tt.errorMsg { + t.Errorf("expected error %q, got %q", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func Test_validateRepositoryRulesetConditionsFieldForBranchAndTagTargets(t *testing.T) { + tests := []struct { + name string + target github.RulesetTarget + conditions map[string]any + expectError bool + errorMsg string + }{ + { + name: "valid branch target with ref_name", + target: github.RulesetTargetBranch, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, + }, + expectError: false, + }, + { + name: "valid tag target with ref_name", + target: github.RulesetTargetTag, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"v*"}, "exclude": []any{}}}, + }, + expectError: false, + }, + { + name: "invalid branch target without ref_name", + target: github.RulesetTargetBranch, + conditions: map[string]any{}, + expectError: true, + errorMsg: "ref_name must be set for branch target", + }, + { + name: "invalid tag target without ref_name", + target: github.RulesetTargetTag, + conditions: map[string]any{}, + expectError: true, + errorMsg: "ref_name must be set for tag target", + }, + { + name: "invalid branch target with nil ref_name", + target: github.RulesetTargetBranch, + conditions: map[string]any{"ref_name": nil}, + expectError: true, + errorMsg: "ref_name must be set for branch target", + }, + { + name: "invalid tag target with empty ref_name slice", + target: github.RulesetTargetTag, + conditions: map[string]any{"ref_name": []any{}}, + expectError: true, + errorMsg: "ref_name must be set for tag target", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConditionsFieldForBranchAndTagTargets(t.Context(), tt.target, tt.conditions, false) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if err.Error() != tt.errorMsg { + t.Errorf("expected error %q, got %q", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func Test_validateConditionsFieldForBranchAndTagTargets(t *testing.T) { + tests := []struct { + name string + target github.RulesetTarget + conditions map[string]any + expectError bool + errorMsg string + }{ + { + name: "valid branch target with ref_name and repository_name", + target: github.RulesetTargetBranch, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, + "repository_name": []any{map[string]any{"include": []any{"~ALL"}, "exclude": []any{}}}, + }, + expectError: false, + }, + { + name: "valid tag target with ref_name and repository_id", + target: github.RulesetTargetTag, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"v*"}, "exclude": []any{}}}, + "repository_id": []any{123, 456}, + }, + expectError: false, + }, + { + name: "invalid branch target without ref_name", + target: github.RulesetTargetBranch, + conditions: map[string]any{ + "repository_name": []any{map[string]any{"include": []any{"~ALL"}, "exclude": []any{}}}, + }, + expectError: true, + errorMsg: "ref_name must be set for branch target", + }, + { + name: "invalid branch target without repository_name or repository_id", + target: github.RulesetTargetBranch, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, + }, + expectError: true, + errorMsg: "either repository_name or repository_id must be set for branch target", + }, + { + name: "invalid tag target with nil repository_name and repository_id", + target: github.RulesetTargetTag, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"v*"}, "exclude": []any{}}}, + "repository_name": nil, + "repository_id": nil, + }, + expectError: true, + errorMsg: "either repository_name or repository_id must be set for tag target", + }, + { + name: "invalid branch target with empty repository_name and repository_id slices", + target: github.RulesetTargetBranch, + conditions: map[string]any{ + "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, + "repository_name": []any{}, + "repository_id": []any{}, + }, + expectError: true, + errorMsg: "either repository_name or repository_id must be set for branch target", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConditionsFieldForBranchAndTagTargets(t.Context(), tt.target, tt.conditions, true) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if err.Error() != tt.errorMsg { + t.Errorf("expected error %q, got %q", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func Test_ruleListsDoNotOverlap(t *testing.T) { + for _, pushRule := range pushOnlyRules { + for _, branchTagRule := range branchTagOnlyRules { + if pushRule == branchTagRule { + t.Errorf("rule %q appears in both pushOnlyRules and branchTagOnlyRules", pushRule) + } + } + } +} diff --git a/website/docs/r/organization_ruleset.html.markdown b/website/docs/r/organization_ruleset.html.markdown index 949b806637..ef9e803d9d 100644 --- a/website/docs/r/organization_ruleset.html.markdown +++ b/website/docs/r/organization_ruleset.html.markdown @@ -65,24 +65,23 @@ resource "github_organization_ruleset" "example" { } } -# Example with push ruleset +# Example with push ruleset +# Note: Push targets must NOT have ref_name in conditions, only repository_name or repository_id resource "github_organization_ruleset" "example_push" { name = "example_push" target = "push" enforcement = "active" conditions { - ref_name { - include = ["~ALL"] - exclude = [] - } repository_name { - include = ["~ALL"] + include = ["~ALL"] exclude = [] } } rules { + # Push targets only support these rules: + # file_path_restriction, max_file_size, max_file_path_length, file_extension_restriction file_path_restriction { restricted_file_paths = [".github/workflows/*", "*.env"] } @@ -114,12 +113,14 @@ resource "github_organization_ruleset" "example_push" { - `bypass_actors` - (Optional) (Block List) The actors that can bypass the rules in this ruleset. (see [below for nested schema](#bypass_actors)) -- `conditions` - (Optional) (Block List, Max: 1) Parameters for an organization ruleset condition. `ref_name` is required alongside one of `repository_name` or `repository_id`. (see [below for nested schema](#conditions)) +- `conditions` - (Optional) (Block List, Max: 1) Parameters for an organization ruleset condition. For `branch` and `tag` targets, `ref_name` is required alongside one of `repository_name` or `repository_id`. For `push` targets, `ref_name` must NOT be set - only `repository_name` or `repository_id` should be used. (see [below for nested schema](#conditions)) #### Rules #### The `rules` block supports the following: +~> **Note:** Rules are target-specific. `branch` and `tag` targets support rules like `creation`, `deletion`, `pull_request`, `required_status_checks`, etc. `push` targets only support `file_path_restriction`, `max_file_size`, `max_file_path_length`, and `file_extension_restriction`. Using the wrong rules for a target will result in a validation error. + - `branch_name_pattern` - (Optional) (Block List, Max: 1) Parameters to be used for the branch_name_pattern rule. This rule only applies to repositories within an enterprise, it cannot be applied to repositories owned by individuals or regular organizations. Conflicts with `tag_name_pattern` as it only applies to rulesets with target `branch`. (see [below for nested schema](#rulesbranch_name_pattern)) - `commit_author_email_pattern` - (Optional) (Block List, Max: 1) Parameters to be used for the commit_author_email_pattern rule. This rule only applies to repositories within an enterprise, it cannot be applied to repositories owned by individuals or regular organizations. (see [below for nested schema](#rulescommit_author_email_pattern)) @@ -239,7 +240,7 @@ The `rules` block supports the following: - `do_not_enforce_on_create` - (Optional) (Boolean) Allow repositories and branches to be created if a check would otherwise prohibit it. Defaults to `false`. -- `required_workflow` - (Required) (Block Set, Min: 1) Actions workflows that are required. Multiple can be defined. (see [below for nested schema](#rulesrequired_workflows.required_workflow)) +- `required_workflow` - (Required) (Block Set, Min: 1) Actions workflows that are required. Multiple can be defined. (see [below for nested schema](#rulesrequired_workflowsrequired_workflow)) #### rules.required_workflows.required_workflow #### @@ -251,7 +252,7 @@ The `rules` block supports the following: #### rules.required_code_scanning #### -- `required_code_scanning_tool` - (Required) (Block Set, Min: 1) Actions code scanning tools that are required. Multiple can be defined. (see [below for nested schema](#rulesrequired_workflows.required_code_scanning_tool)) +- `required_code_scanning_tool` - (Required) (Block Set, Min: 1) Actions code scanning tools that are required. Multiple can be defined. (see [below for nested schema](#rulesrequired_code_scanningrequired_code_scanning_tool)) #### rules.required_code_scanning.required_code_scanning_tool #### @@ -305,12 +306,14 @@ The `rules` block supports the following: #### conditions #### -- `ref_name` - (Required) (Block List, Min: 1, Max: 1) (see [below for nested schema](#conditions.ref_name)) +- `ref_name` - (Optional) (Block List, Max: 1) Required for `branch` and `tag` targets. Must NOT be set for `push` targets. (see [below for nested schema](#conditionsref_name)) - `repository_id` (Optional) (List of Number) The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass. Conflicts with `repository_name`. -- `repository_name` (Optional) (Block List, Max: 1) Conflicts with `repository_id`. (see [below for nested schema](#conditions.repository_name)) +- `repository_name` (Optional) (Block List, Max: 1) Conflicts with `repository_id`. (see [below for nested schema](#conditionsrepository_name)) One of `repository_id` and `repository_name` must be set for the rule to target any repositories. +~> **Note:** For `push` targets, do not include `ref_name` in conditions. Push rulesets operate on file content, not on refs. + #### conditions.ref_name #### - `exclude` - (Required) (List of String) Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match. diff --git a/website/docs/r/repository_ruleset.html.markdown b/website/docs/r/repository_ruleset.html.markdown index 37893cecb9..264a716fc8 100644 --- a/website/docs/r/repository_ruleset.html.markdown +++ b/website/docs/r/repository_ruleset.html.markdown @@ -98,7 +98,7 @@ resource "github_repository_ruleset" "example_push" { - `bypass_actors` - (Optional) (Block List) The actors that can bypass the rules in this ruleset. (see [below for nested schema](#bypass_actors)) -- `conditions` - (Optional) (Block List, Max: 1) Parameters for a repository ruleset ref name condition. (see [below for nested schema](#conditions)) +- `conditions` - (Optional) (Block List, Max: 1) Parameters for a repository ruleset condition. For `branch` and `tag` targets, `ref_name` is required. For `push` targets, `ref_name` must NOT be set - conditions are optional for push targets. (see [below for nested schema](#conditions)) - `repository` - (Required) (String) Name of the repository to apply ruleset to. @@ -106,6 +106,8 @@ resource "github_repository_ruleset" "example_push" { The `rules` block supports the following: +~> **Note:** Rules are target-specific. `branch` and `tag` targets support rules like `creation`, `deletion`, `pull_request`, `required_status_checks`, etc. `push` targets only support `file_path_restriction`, `max_file_size`, `max_file_path_length`, and `file_extension_restriction`. Using the wrong rules for a target will result in a validation error. + - `branch_name_pattern` - (Optional) (Block List, Max: 1) Parameters to be used for the branch_name_pattern rule. This rule only applies to repositories within an enterprise, it cannot be applied to repositories owned by individuals or regular organizations. Conflicts with `tag_name_pattern` as it only applied to rulesets with target `branch`. (see [below for nested schema](#rulesbranch_name_pattern)) - `commit_author_email_pattern` - (Optional) (Block List, Max: 1) Parameters to be used for the commit_author_email_pattern rule. This rule only applies to repositories within an enterprise, it cannot be applied to repositories owned by individuals or regular organizations. (see [below for nested schema](#rulescommit_author_email_pattern)) @@ -294,7 +296,7 @@ The `rules` block supports the following: #### conditions #### -- `ref_name` - (Required) (Block List, Min: 1, Max: 1) (see [below for nested schema](#conditions.ref_name)) +- `ref_name` - (Required) (Block List, Min: 1, Max: 1) (see [below for nested schema](#conditionsref_name)) #### conditions.ref_name ####