diff --git a/.golangci.yml b/.golangci.yml index 0be5721693..50d996d7a4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,7 @@ linters: - misspell - modernize - nilerr + - nilnesserr - predeclared - staticcheck - unconvert diff --git a/github/resource_github_issue_labels_test.go b/github/resource_github_issue_labels_test.go index d512288081..a642991f2e 100644 --- a/github/resource_github_issue_labels_test.go +++ b/github/resource_github_issue_labels_test.go @@ -81,7 +81,7 @@ func TestAccGithubIssueLabels(t *testing.T) { } func testAccGithubIssueLabelsConfig(repoName string, labels []map[string]any) string { - resource := "" + config := "" if labels != nil { var dynamic strings.Builder for _, label := range labels { @@ -94,7 +94,7 @@ func testAccGithubIssueLabelsConfig(repoName string, labels []map[string]any) st `, label["name"], label["color"], label["description"])) } - resource = fmt.Sprintf(` + config = fmt.Sprintf(` resource "github_issue_labels" "test" { repository = github_repository.test.name @@ -110,7 +110,7 @@ func testAccGithubIssueLabelsConfig(repoName string, labels []map[string]any) st } %s - `, repoName, resource) + `, repoName, config) } func TestAccGithubIssueLabelsArchived(t *testing.T) { diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go index eea93ffc0a..46fdbbb5cc 100644 --- a/github/resource_github_organization_ruleset.go +++ b/github/resource_github_organization_ruleset.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "strconv" "github.com/google/go-github/v81/github" @@ -26,6 +27,8 @@ func resourceGithubOrganizationRuleset() *schema.Resource { SchemaVersion: 1, + CustomizeDiff: resourceGithubOrganizationRulesetValidate, + Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, @@ -34,16 +37,17 @@ func resourceGithubOrganizationRuleset() *schema.Resource { 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 any rule created with that doesn't show up in the UI, nor does it have any rules. + ValidateFunc: validation.StringInSlice([]string{string(TargetBranch), string(TargetTag), string(TargetPush)}, false), + Description: "The target of the ruleset. Possible values are `branch`, `tag` and `push`.", }, "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`.", + 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 @@ -62,7 +66,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { 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", + 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, @@ -87,13 +91,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 +124,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{ @@ -256,9 +262,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, @@ -286,7 +293,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, @@ -465,9 +472,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: toDiagFunc(validation.StringMatch(regexp.MustCompile(`^\.github\/workflows\/.*$`), "Path must be in the .github/workflows directory"), "path"), + Description: "The path to the workflow YAML definition file.", }, "ref": { Type: schema.TypeString, @@ -589,8 +597,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.", }, }, } @@ -688,7 +697,7 @@ func resourceGithubOrganizationRulesetRead(ctx context.Context, d *schema.Resour _ = 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("conditions", flattenConditionsWithContext(ctx, ruleset.GetConditions(), true)) _ = d.Set("rules", flattenRules(ruleset.Rules, true)) _ = d.Set("node_id", ruleset.GetNodeID()) _ = d.Set("etag", resp.Header.Get("ETag")) @@ -804,7 +813,7 @@ func resourceGithubOrganizationRulesetImport(ctx context.Context, d *schema.Reso "owner": owner, "ruleset_id": rulesetID, }) - return []*schema.ResourceData{d}, fmt.Errorf("`ruleset_id` must be present") + return []*schema.ResourceData{d}, errors.New("`ruleset_id` must be present") } tflog.Debug(ctx, fmt.Sprintf("Importing organization ruleset: %s/%d", owner, rulesetID), map[string]any{ @@ -831,3 +840,17 @@ func resourceGithubOrganizationRulesetImport(ctx context.Context, d *schema.Reso return []*schema.ResourceData{d}, nil } + +func resourceGithubOrganizationRulesetValidate(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 774315e6db..e35d41ffca 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 = < 2 { @@ -31,7 +32,6 @@ func resourceGithubRepositoryFile() *schema.Resource { owner := meta.(*Owner).name repo, file := splitRepoFilePath(parts[0]) // test if a file exists in a repository. - ctx := context.WithValue(context.Background(), ctxId, fmt.Sprintf("%s/%s", repo, file)) opts := &github.RepositoryContentGetOptions{} if len(parts) == 2 { opts.Ref = parts[1] @@ -187,10 +187,9 @@ func resourceGithubRepositoryFileOptions(d *schema.ResourceData) (*github.Reposi return opts, nil } -func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error { +func resourceGithubRepositoryFileCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client owner := meta.(*Owner).name - ctx := context.Background() repo := d.Get("repository").(string) file := d.Get("file").(string) @@ -208,8 +207,8 @@ func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error if _, hasSourceSHA := d.GetOk("autocreate_branch_source_sha"); !hasSourceSHA { ref, _, err := client.Git.GetRef(ctx, owner, repo, sourceBranchRefName) if err != nil { - return fmt.Errorf("error querying GitHub branch reference %s/%s (%s): %w", - owner, repo, sourceBranchRefName, err) + return diag.Errorf("error querying GitHub branch reference %s/%s (%s): %s", + owner, repo, sourceBranchRefName, err.Error()) } _ = d.Set("autocreate_branch_source_sha", *ref.Object.SHA) } @@ -218,10 +217,10 @@ func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error Ref: branchRefName, SHA: sourceBranchSHA, }); err != nil { - return err + return diag.FromErr(err) } } else { - return err + return diag.FromErr(err) } } checkOpt.Ref = branch.(string) @@ -229,7 +228,7 @@ func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error opts, err := resourceGithubRepositoryFileOptions(d) if err != nil { - return err + return diag.FromErr(err) } if opts.Message == nil { @@ -243,11 +242,11 @@ func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error if resp != nil { if resp.StatusCode != 404 { // 404 is a valid response if the file does not exist - return err + return diag.FromErr(err) } } else { // Response should be non-nil - return err + return diag.FromErr(err) } } @@ -258,28 +257,28 @@ func resourceGithubRepositoryFileCreate(d *schema.ResourceData, meta any) error opts.SHA = fileContent.SHA } else { // Error if overwriting a file is not requested - return fmt.Errorf("refusing to overwrite existing file: configure `overwrite_on_create` to `true` to override") + return diag.Errorf("refusing to overwrite existing file: configure `overwrite_on_create` to `true` to override") } } + log.Printf("[DEBUG] Creating or overwriting a repository file: %s/%s/%s", owner, repo, file) // Create a new or overwritten file create, _, err := client.Repositories.CreateFile(ctx, owner, repo, file, opts) if err != nil { - return err + return diag.FromErr(err) } d.SetId(fmt.Sprintf("%s/%s", repo, file)) if err = d.Set("commit_sha", create.GetSHA()); err != nil { - return err + return diag.FromErr(err) } - return resourceGithubRepositoryFileRead(d, meta) + return resourceGithubRepositoryFileRead(ctx, d, meta) } -func resourceGithubRepositoryFileRead(d *schema.ResourceData, meta any) error { +func resourceGithubRepositoryFileRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client owner := meta.(*Owner).name - ctx := context.WithValue(context.Background(), ctxId, d.Id()) repo, file := splitRepoFilePath(d.Id()) @@ -304,7 +303,7 @@ func resourceGithubRepositoryFileRead(d *schema.ResourceData, meta any) error { if err != nil { var errorResponse *github.ErrorResponse if errors.As(err, &errorResponse) && errorResponse.Response.StatusCode == http.StatusTooManyRequests { - return err + return diag.FromErr(err) } } if fc == nil { @@ -316,35 +315,35 @@ func resourceGithubRepositoryFileRead(d *schema.ResourceData, meta any) error { content, err := fc.GetContent() if err != nil { - return err + return diag.FromErr(err) } if err = d.Set("content", content); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("repository", repo); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("file", file); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("sha", fc.GetSHA()); err != nil { - return err + return diag.FromErr(err) } var commit *github.RepositoryCommit parsedUrl, err := url.Parse(fc.GetURL()) if err != nil { - return err + return diag.FromErr(err) } parsedQuery, err := url.ParseQuery(parsedUrl.RawQuery) if err != nil { - return err + return diag.FromErr(err) } ref := parsedQuery["ref"][0] if err = d.Set("ref", ref); err != nil { - return err + return diag.FromErr(err) } // Use the SHA to lookup the commit info if we know it, otherwise loop through commits @@ -356,12 +355,12 @@ func resourceGithubRepositoryFileRead(d *schema.ResourceData, meta any) error { commit, err = getFileCommit(ctx, client, owner, repo, file, ref) } if err != nil { - return err + return diag.FromErr(err) } log.Printf("[DEBUG] Found file: %s/%s/%s, in commit SHA: %s ", owner, repo, file, commit.GetSHA()) if err = d.Set("commit_sha", commit.GetSHA()); err != nil { - return err + return diag.FromErr(err) } commit_author := commit.Commit.GetCommitter().GetName() @@ -373,23 +372,22 @@ func resourceGithubRepositoryFileRead(d *schema.ResourceData, meta any) error { // read from state if author+email is set explicitly, and if it was not github signing it for you previously if commit_author != "GitHub" && commit_email != "noreply@github.com" && hasCommitAuthor && hasCommitEmail { if err = d.Set("commit_author", commit_author); err != nil { - return err + return diag.FromErr(err) } if err = d.Set("commit_email", commit_email); err != nil { - return err + return diag.FromErr(err) } } if err = d.Set("commit_message", commit.GetCommit().GetMessage()); err != nil { - return err + return diag.FromErr(err) } return nil } -func resourceGithubRepositoryFileUpdate(d *schema.ResourceData, meta any) error { +func resourceGithubRepositoryFileUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client owner := meta.(*Owner).name - ctx := context.Background() repo := d.Get("repository").(string) file := d.Get("file").(string) @@ -405,8 +403,8 @@ func resourceGithubRepositoryFileUpdate(d *schema.ResourceData, meta any) error if _, hasSourceSHA := d.GetOk("autocreate_branch_source_sha"); !hasSourceSHA { ref, _, err := client.Git.GetRef(ctx, owner, repo, sourceBranchRefName) if err != nil { - return fmt.Errorf("error querying GitHub branch reference %s/%s (%s): %w", - owner, repo, sourceBranchRefName, err) + return diag.Errorf("error querying GitHub branch reference %s/%s (%s): %s", + owner, repo, sourceBranchRefName, err.Error()) } _ = d.Set("autocreate_branch_source_sha", *ref.Object.SHA) } @@ -415,17 +413,17 @@ func resourceGithubRepositoryFileUpdate(d *schema.ResourceData, meta any) error Ref: branchRefName, SHA: sourceBranchSHA, }); err != nil { - return err + return diag.FromErr(err) } } else { - return err + return diag.FromErr(err) } } } opts, err := resourceGithubRepositoryFileOptions(d) if err != nil { - return err + return diag.FromErr(err) } if *opts.Message == fmt.Sprintf("Add %s", file) { @@ -435,20 +433,19 @@ func resourceGithubRepositoryFileUpdate(d *schema.ResourceData, meta any) error create, _, err := client.Repositories.CreateFile(ctx, owner, repo, file, opts) if err != nil { - return err + return diag.FromErr(err) } if err = d.Set("commit_sha", create.GetSHA()); err != nil { - return err + return diag.FromErr(err) } - return resourceGithubRepositoryFileRead(d, meta) + return resourceGithubRepositoryFileRead(ctx, d, meta) } -func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta any) error { +func resourceGithubRepositoryFileDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client owner := meta.(*Owner).name - ctx := context.Background() repo := d.Get("repository").(string) file := d.Get("file").(string) @@ -457,7 +454,7 @@ func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta any) error opts, err := resourceGithubRepositoryFileOptions(d) if err != nil { - return err + return diag.FromErr(err) } if *opts.Message == fmt.Sprintf("Add %s", file) { @@ -476,8 +473,8 @@ func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta any) error if _, hasSourceSHA := d.GetOk("autocreate_branch_source_sha"); !hasSourceSHA { ref, _, err := client.Git.GetRef(ctx, owner, repo, sourceBranchRefName) if err != nil { - return fmt.Errorf("error querying GitHub branch reference %s/%s (%s): %w", - owner, repo, sourceBranchRefName, err) + return diag.Errorf("error querying GitHub branch reference %s/%s (%s): %s", + owner, repo, sourceBranchRefName, err.Error()) } _ = d.Set("autocreate_branch_source_sha", *ref.Object.SHA) } @@ -486,10 +483,10 @@ func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta any) error Ref: branchRefName, SHA: sourceBranchSHA, }); err != nil { - return err + return diag.FromErr(err) } } else { - return err + return diag.FromErr(err) } } branch = b.(string) @@ -497,7 +494,7 @@ func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta any) error } _, _, err = client.Repositories.DeleteFile(ctx, owner, repo, file, opts) - return handleArchivedRepoDelete(err, "repository file", file, owner, repo) + return diag.FromErr(handleArchivedRepoDelete(err, "repository file", file, owner, repo)) } func autoBranchDiffSuppressFunc(k, _, _ string, d *schema.ResourceData) bool { diff --git a/github/resource_github_repository_ruleset.go b/github/resource_github_repository_ruleset.go index ebb5400a75..ad97642b83 100644 --- a/github/resource_github_repository_ruleset.go +++ b/github/resource_github_repository_ruleset.go @@ -3,13 +3,12 @@ package github import ( "context" "errors" - "fmt" - "log" "net/http" "regexp" "strconv" "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -27,6 +26,8 @@ func resourceGithubRepositoryRuleset() *schema.Resource { SchemaVersion: 1, + CustomizeDiff: resourceGithubRepositoryRulesetValidate, + Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, @@ -37,7 +38,7 @@ func resourceGithubRepositoryRuleset() *schema.Resource { "target": { Type: schema.TypeString, Required: true, - ValidateFunc: validation.StringInSlice([]string{"branch", "push", "tag"}, false), + ValidateFunc: validation.StringInSlice([]string{string(TargetBranch), string(TargetPush), string(TargetTag)}, false), Description: "Possible values are `branch`, `push` and `tag`.", }, "repository": { @@ -659,8 +660,7 @@ func resourceGithubRepositoryRulesetRead(ctx context.Context, d *schema.Resource return nil } if ghErr.Response.StatusCode == http.StatusNotFound { - log.Printf("[INFO] Removing ruleset %s/%s: %d from state because it no longer exists in GitHub", - owner, repoName, rulesetID) + tflog.Info(ctx, "Removing ruleset from state because it no longer exists in GitHub", map[string]any{"owner": owner, "repo_name": repoName, "ruleset_id": rulesetID}) d.SetId("") return nil } @@ -669,8 +669,7 @@ func resourceGithubRepositoryRulesetRead(ctx context.Context, d *schema.Resource } if ruleset == nil { - log.Printf("[INFO] Removing ruleset %s/%s: %d from state because it no longer exists in GitHub (empty response)", - owner, repoName, rulesetID) + tflog.Info(ctx, "Removing ruleset from state because it no longer exists in GitHub (empty response)", map[string]any{"owner": owner, "repo_name": repoName, "ruleset_id": rulesetID}) d.SetId("") return nil } @@ -707,7 +706,7 @@ func resourceGithubRepositoryRulesetUpdate(ctx context.Context, d *schema.Resour return diag.FromErr(err) } if repo.GetArchived() { - log.Printf("[INFO] Repository %s/%s is archived, skipping ruleset update", owner, repoName) + tflog.Info(ctx, "Repository is archived, skipping ruleset update", map[string]any{"owner": owner, "repo_name": repoName}) return nil } @@ -733,9 +732,9 @@ func resourceGithubRepositoryRulesetDelete(ctx context.Context, d *schema.Resour return diag.FromErr(unconvertibleIdErr(d.Id(), err)) } - log.Printf("[DEBUG] Deleting repository ruleset: %s/%s: %d", owner, repoName, rulesetID) + tflog.Debug(ctx, "Deleting repository ruleset", map[string]any{"owner": owner, "repo_name": repoName, "ruleset_id": rulesetID}) _, err = client.Repositories.DeleteRuleset(ctx, owner, repoName, rulesetID) - return diag.FromErr(handleArchivedRepoDelete(err, "repository ruleset", fmt.Sprintf("%d", rulesetID), owner, repoName)) + return diag.FromErr(handleArchivedRepoDelete(err, "repository ruleset", strconv.FormatInt(rulesetID, 10), owner, repoName)) } func resourceGithubRepositoryRulesetImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { @@ -749,13 +748,13 @@ func resourceGithubRepositoryRulesetImport(ctx context.Context, d *schema.Resour return []*schema.ResourceData{d}, unconvertibleIdErr(rulesetIDStr, err) } if rulesetID == 0 { - return []*schema.ResourceData{d}, fmt.Errorf("`ruleset_id` must be present") + return []*schema.ResourceData{d}, errors.New("`ruleset_id` must be present") } - log.Printf("[DEBUG] Importing repository ruleset with ID: %d, for repository: %s", rulesetID, repoName) - client := meta.(*Owner).v3client owner := meta.(*Owner).name + tflog.Debug(ctx, "Importing repository ruleset", map[string]any{"owner": owner, "repo_name": repoName, "ruleset_id": rulesetID}) + repository, _, err := client.Repositories.Get(ctx, owner, repoName) if repository == nil || err != nil { return []*schema.ResourceData{d}, err @@ -770,3 +769,17 @@ func resourceGithubRepositoryRulesetImport(ctx context.Context, d *schema.Resour return []*schema.ResourceData{d}, nil } + +func resourceGithubRepositoryRulesetValidate(ctx context.Context, d *schema.ResourceDiff, meta any) error { + err := validateRulesetConditions(ctx, d, false) + if err != nil { + return err + } + + err = validateRulesetRules(ctx, d) + if err != nil { + return err + } + + return nil +} diff --git a/github/resource_github_repository_ruleset_test.go b/github/resource_github_repository_ruleset_test.go index 54161a0af0..c694858073 100644 --- a/github/resource_github_repository_ruleset_test.go +++ b/github/resource_github_repository_ruleset_test.go @@ -13,6 +13,13 @@ import ( ) func TestAccGithubRepositoryRuleset(t *testing.T) { + baseRepoVisibility := "public" + + if testAccConf.authMode == enterprise { + // This enables repos to be created even in GHEC EMU + baseRepoVisibility = "private" + } + t.Run("create_branch_ruleset", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) repoName := fmt.Sprintf("%srepo-ruleset-%s", testResourcePrefix, randomID) @@ -23,6 +30,8 @@ resource "github_repository" "test" { auto_init = true default_branch = "main" vulnerability_alerts = true + + visibility = "%s" } resource "github_repository_environment" "example" { @@ -80,34 +89,36 @@ resource "github_repository_ruleset" "test" { required_signatures = false pull_request { - dismiss_stale_reviews_on_push = true - require_code_owner_review = true - require_last_push_approval = true + allowed_merge_methods = ["merge", "squash", "rebase"] required_approving_review_count = 2 required_review_thread_resolution = true + require_code_owner_review = true + dismiss_stale_reviews_on_push = true + require_last_push_approval = true } required_status_checks { - do_not_enforce_on_create = true - strict_required_status_checks_policy = true required_check { context = "ci" } - } - non_fast_forward = true + strict_required_status_checks_policy = true + do_not_enforce_on_create = true + } required_code_scanning { required_code_scanning_tool { - alerts_threshold = "errors" - security_alerts_threshold = "high_or_higher" - tool = "CodeQL" + alerts_threshold = "errors" + security_alerts_threshold = "high_or_higher" + tool = "CodeQL" } } + + non_fast_forward = true } } -`, repoName) +`, repoName, baseRepoVisibility) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -143,6 +154,8 @@ resource "github_repository_ruleset" "test" { name = "%s" auto_init = false vulnerability_alerts = true + + visibility = "%s" } resource "github_repository_environment" "example" { @@ -172,10 +185,10 @@ resource "github_repository_ruleset" "test" { } } } -`, repoName) +`, repoName, baseRepoVisibility) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, enterprise) }, + PreCheck: func() { skipUnauthenticated(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { @@ -224,7 +237,7 @@ resource "github_repository_ruleset" "test" { } max_file_size { - max_file_size = 1048576 + max_file_size = 99 } file_extension_restriction { @@ -236,7 +249,7 @@ resource "github_repository_ruleset" "test" { `, repoName) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + PreCheck: func() { skipUnlessEnterprise(t) }, Providers: testAccProviders, Steps: []resource.TestStep{ { @@ -245,14 +258,14 @@ resource "github_repository_ruleset" "test" { resource.TestCheckResourceAttr("github_repository_ruleset.test", "name", "test-push"), resource.TestCheckResourceAttr("github_repository_ruleset.test", "target", "push"), resource.TestCheckResourceAttr("github_repository_ruleset.test", "enforcement", "active"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.#", "2"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.0.actor_type", "DeployKey"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.0.bypass_mode", "always"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.1.actor_id", "5"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.1.actor_type", "RepositoryRole"), - resource.TestCheckResourceAttr("github_organization_ruleset.test", "bypass_actors.1.bypass_mode", "always"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.#", "2"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.0.actor_type", "DeployKey"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.0.bypass_mode", "always"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.1.actor_id", "5"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.1.actor_type", "RepositoryRole"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.1.bypass_mode", "always"), resource.TestCheckResourceAttr("github_repository_ruleset.test", "rules.0.file_path_restriction.0.restricted_file_paths.0", "test.txt"), - resource.TestCheckResourceAttr("github_repository_ruleset.test", "rules.0.max_file_size.0.max_file_size", "1048576"), + resource.TestCheckResourceAttr("github_repository_ruleset.test", "rules.0.max_file_size.0.max_file_size", "99"), resource.TestCheckResourceAttr("github_repository_ruleset.test", "rules.0.file_extension_restriction.0.restricted_file_extensions.0", "*.zip"), ), }, @@ -263,18 +276,20 @@ resource "github_repository_ruleset" "test" { t.Run("update_ruleset_name", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) repoName := fmt.Sprintf("%srepo-ruleset-rename-%s", testResourcePrefix, randomID) - name := fmt.Sprintf(`ruleset-%[1]s`, randomID) - nameUpdated := fmt.Sprintf(`%[1]s-renamed`, randomID) + name := fmt.Sprintf("ruleset-%s", randomID) + nameUpdated := fmt.Sprintf("%s-renamed", name) config := ` resource "github_repository" "test" { - name = "%[1]s" - description = "Terraform acceptance tests %[2]s" + name = "%s" + description = "Terraform acceptance tests %s" vulnerability_alerts = true + + visibility = "%s" } resource "github_repository_ruleset" "test" { - name = "%[3]s" + name = "%s" repository = github_repository.test.id target = "branch" enforcement = "active" @@ -290,13 +305,13 @@ resource "github_repository_ruleset" "test" { ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(config, repoName, randomID, name), + Config: fmt.Sprintf(config, repoName, randomID, baseRepoVisibility, name), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("github_repository_ruleset.test", "name", name), ), }, { - Config: fmt.Sprintf(config, repoName, randomID, nameUpdated), + Config: fmt.Sprintf(config, repoName, randomID, baseRepoVisibility, nameUpdated), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("github_repository_ruleset.test", "name", nameUpdated), ), @@ -309,48 +324,25 @@ resource "github_repository_ruleset" "test" { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) repoName := fmt.Sprintf("%srepo-ruleset-bypass-%s", testResourcePrefix, randomID) - config := fmt.Sprintf(` -resource "github_repository" "test" { - name = "%s" - description = "Terraform acceptance tests %[1]s" - auto_init = true + bypassActorsConfig := ` +bypass_actors { + actor_type = "DeployKey" + bypass_mode = "always" } -resource "github_repository_ruleset" "test" { - name = "test-bypass" - repository = github_repository.test.id - target = "branch" - enforcement = "active" - - bypass_actors { - actor_type = "DeployKey" - bypass_mode = "always" - } - - bypass_actors { - actor_id = 5 - actor_type = "RepositoryRole" - bypass_mode = "always" - } - - conditions { - ref_name { - include = ["~ALL"] - exclude = [] - } - } - - rules { - creation = true - } +bypass_actors { + actor_id = 5 + actor_type = "RepositoryRole" + bypass_mode = "always" } -`, repoName) - - configUpdated := fmt.Sprintf(` +` + baseConfig := ` resource "github_repository" "test" { name = "%s" description = "Terraform acceptance tests %[1]s" auto_init = true + + visibility = "%s" } resource "github_repository_ruleset" "test" { @@ -359,6 +351,8 @@ resource "github_repository_ruleset" "test" { target = "branch" enforcement = "active" + %s + conditions { ref_name { include = ["~ALL"] @@ -370,11 +364,13 @@ resource "github_repository_ruleset" "test" { creation = true } } -`, repoName) +` + config := fmt.Sprintf(baseConfig, repoName, baseRepoVisibility, bypassActorsConfig) + configUpdated := fmt.Sprintf(baseConfig, repoName, baseRepoVisibility, "") resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnauthenticated(t) }, - Providers: testAccProviders, + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, @@ -399,11 +395,13 @@ resource "github_repository_ruleset" "test" { bypassMode := "always" bypassModeUpdated := "exempt" - config := fmt.Sprintf(` + config := ` resource "github_repository" "test" { name = "%s" description = "Terraform acceptance tests %s" auto_init = true + + visibility = "%s" } resource "github_repository_ruleset" "test" { @@ -429,20 +427,20 @@ resource "github_repository_ruleset" "test" { creation = true } } -`, repoName, randomID, bypassMode) +` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, Providers: testAccProviders, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(config, randomID, bypassMode), + Config: fmt.Sprintf(config, repoName, randomID, baseRepoVisibility, bypassMode), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.0.bypass_mode", bypassMode), ), }, { - Config: fmt.Sprintf(config, randomID, bypassModeUpdated), + Config: fmt.Sprintf(config, repoName, randomID, baseRepoVisibility, bypassModeUpdated), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("github_repository_ruleset.test", "bypass_actors.0.bypass_mode", bypassModeUpdated), ), @@ -461,7 +459,9 @@ resource "github_repository_ruleset" "test" { description = "Terraform acceptance tests %s" auto_init = true default_branch = "main" - vulnerability_alerts = true + vulnerability_alerts = true + + visibility = "%s" } resource "github_repository_environment" "example" { @@ -486,7 +486,7 @@ resource "github_repository_ruleset" "test" { creation = true } } - `, repoName, randomID) + `, repoName, randomID, baseRepoVisibility) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnauthenticated(t) }, @@ -508,6 +508,13 @@ resource "github_repository_ruleset" "test" { } func TestAccGithubRepositoryRulesetArchived(t *testing.T) { + baseRepoVisibility := "public" + + if testAccConf.authMode == enterprise { + // This enables repos to be created even in GHEC EMU + baseRepoVisibility = "private" + } + t.Run("skips update and delete on archived repository", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) repoName := fmt.Sprintf("%srepo-ruleset-arch-%s", testResourcePrefix, randomID) @@ -516,6 +523,8 @@ func TestAccGithubRepositoryRulesetArchived(t *testing.T) { name = "%s" auto_init = true archived = false + + visibility = "%s" } resource "github_repository_ruleset" "test" { @@ -525,11 +534,11 @@ func TestAccGithubRepositoryRulesetArchived(t *testing.T) { enforcement = "active" rules { creation = true } } - `, repoName) + `, repoName, baseRepoVisibility) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, individual) }, - Providers: testAccProviders, + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, Steps: []resource.TestStep{ {Config: config}, {Config: strings.Replace(config, "archived = false", "archived = true", 1)}, @@ -546,6 +555,8 @@ func TestAccGithubRepositoryRulesetArchived(t *testing.T) { name = "%s" auto_init = true archived = true + + visibility = "%s" } resource "github_repository_ruleset" "test" { name = "test" @@ -554,11 +565,11 @@ func TestAccGithubRepositoryRulesetArchived(t *testing.T) { enforcement = "active" rules { creation = true } } - `, repoName) + `, repoName, baseRepoVisibility) resource.Test(t, resource.TestCase{ - PreCheck: func() { skipUnlessMode(t, individual) }, - Providers: testAccProviders, + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, Steps: []resource.TestStep{ {Config: config, ExpectError: regexp.MustCompile("cannot create ruleset on archived repository")}, }, @@ -566,6 +577,177 @@ func TestAccGithubRepositoryRulesetArchived(t *testing.T) { }) } +func TestAccGithubRepositoryRulesetValidation(t *testing.T) { + t.Run("Validates push target rejects ref_name condition", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-push-ref-%s" + auto_init = true + visibility = "private" + vulnerability_alerts = true + } + + resource "github_repository_ruleset" "test" { + name = "test-push-with-ref" + repository = github_repository.test.id + target = "push" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + max_file_size { + max_file_size = 100 + } + } + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("ref_name must not be set for push target"), + }, + }, + }) + }) + + t.Run("Validates push target rejects branch/tag rules", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-push-rules-%s" + auto_init = true + visibility = "private" + vulnerability_alerts = true + } + + resource "github_repository_ruleset" "test" { + name = "test-push-branch-rule" + repository = github_repository.test.id + target = "push" + enforcement = "active" + + rules { + # 'creation' is a branch/tag rule, not valid for push target + creation = true + } + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("rule .* is not valid for push target"), + }, + }, + }) + }) + + t.Run("Validates branch target rejects push-only rules", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-branch-push-%s" + auto_init = true + vulnerability_alerts = true + + visibility = "private" + } + + resource "github_repository_ruleset" "test" { + name = "test-branch-push-rule" + repository = github_repository.test.id + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + # 'max_file_size' is a push-only rule, not valid for branch target + max_file_size { + max_file_size = 100 + } + } + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("rule .* is not valid for branch target"), + }, + }, + }) + }) + + t.Run("Validates tag target rejects push-only rules", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-tag-push-%s" + auto_init = true + vulnerability_alerts = true + + visibility = "private" + } + + resource "github_repository_ruleset" "test" { + name = "test-tag-push-rule" + repository = github_repository.test.id + target = "tag" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + # 'file_path_restriction' is a push-only rule, not valid for tag target + file_path_restriction { + restricted_file_paths = ["secrets/"] + } + } + } + `, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("rule .* is not valid for tag target"), + }, + }, + }) + }) +} + func importRepositoryRulesetByResourcePaths(repoLogicalName, rulesetLogicalName string) resource.ImportStateIdFunc { // test importing using an ID of the form : return func(s *terraform.State) (string, error) { diff --git a/github/resource_github_repository_test.go b/github/resource_github_repository_test.go index 9bfde4fd9a..f146719794 100644 --- a/github/resource_github_repository_test.go +++ b/github/resource_github_repository_test.go @@ -1315,19 +1315,19 @@ func Test_expandPages(t *testing.T) { } func TestGithubRepositoryTopicPassesValidation(t *testing.T) { - resource := resourceGithubRepository() - schema := resource.Schema["topics"].Elem.(*schema.Schema) - diags := schema.ValidateDiagFunc("ef69e1a3-66be-40ca-bb62-4f36186aa292", cty.Path{cty.GetAttrStep{Name: "topic"}}) + repositoryResourceSchema := resourceGithubRepository() + topicsSchema := repositoryResourceSchema.Schema["topics"].Elem.(*schema.Schema) + diags := topicsSchema.ValidateDiagFunc("ef69e1a3-66be-40ca-bb62-4f36186aa292", cty.Path{cty.GetAttrStep{Name: "topic"}}) if diags.HasError() { t.Error(fmt.Errorf("unexpected topic validation failure: %s", diags[0].Summary)) } } func TestGithubRepositoryTopicFailsValidationWhenOverMaxCharacters(t *testing.T) { - resource := resourceGithubRepository() - schema := resource.Schema["topics"].Elem.(*schema.Schema) + repositoryResourceSchema := resourceGithubRepository() + topicsSchema := repositoryResourceSchema.Schema["topics"].Elem.(*schema.Schema) - diags := schema.ValidateDiagFunc(strings.Repeat("a", 51), cty.Path{cty.GetAttrStep{Name: "topic"}}) + diags := topicsSchema.ValidateDiagFunc(strings.Repeat("a", 51), cty.Path{cty.GetAttrStep{Name: "topic"}}) if len(diags) != 1 { t.Error(fmt.Errorf("unexpected number of topic validation failures; expected=1; actual=%d", len(diags))) } @@ -1386,10 +1386,10 @@ func testCheckResourceAttrContains(resourceName, attributeName, substring string } func TestGithubRepositoryNameFailsValidationWhenOverMaxCharacters(t *testing.T) { - resource := resourceGithubRepository() - schema := resource.Schema["name"] + repositoryResourceSchema := resourceGithubRepository() + nameSchema := repositoryResourceSchema.Schema["name"] - diags := schema.ValidateDiagFunc(strings.Repeat("a", 101), cty.GetAttrPath("name")) + diags := nameSchema.ValidateDiagFunc(strings.Repeat("a", 101), cty.GetAttrPath("name")) if len(diags) != 1 { t.Error(fmt.Errorf("unexpected number of name validation failures; expected=1; actual=%d", len(diags))) } @@ -1401,10 +1401,10 @@ func TestGithubRepositoryNameFailsValidationWhenOverMaxCharacters(t *testing.T) } func TestGithubRepositoryNameFailsValidationWithSpace(t *testing.T) { - resource := resourceGithubRepository() - schema := resource.Schema["name"] + repositoryResourceSchema := resourceGithubRepository() + nameSchema := repositoryResourceSchema.Schema["name"] - diags := schema.ValidateDiagFunc("test space", cty.GetAttrPath("name")) + diags := nameSchema.ValidateDiagFunc("test space", cty.GetAttrPath("name")) if len(diags) != 1 { t.Error(fmt.Errorf("unexpected number of name validation failures; expected=1; actual=%d", len(diags))) } diff --git a/github/resource_github_team_repository.go b/github/resource_github_team_repository.go index ecdbe26216..dd9947fe79 100644 --- a/github/resource_github_team_repository.go +++ b/github/resource_github_team_repository.go @@ -138,7 +138,7 @@ func resourceGithubTeamRepositoryRead(d *schema.ResourceData, meta any) error { return nil } } - return err + return repoErr } if err = d.Set("etag", resp.Header.Get("ETag")); err != nil { diff --git a/github/util.go b/github/util.go index 693bf55d0f..4ae7502bfe 100644 --- a/github/util.go +++ b/github/util.go @@ -58,10 +58,10 @@ func wrapErrors(errs []error) diag.Diagnostics { // the old code until all uses of schema.SchemaValidateFunc are gone. func toDiagFunc(oldFunc schema.SchemaValidateFunc, keyName string) schema.SchemaValidateDiagFunc { //nolint:staticcheck return func(i any, path cty.Path) diag.Diagnostics { - warnings, errors := oldFunc(i, keyName) + warnings, errs := oldFunc(i, keyName) var diags diag.Diagnostics - for _, err := range errors { + for _, err := range errs { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: err.Error(), diff --git a/github/util_rules.go b/github/util_rules.go index 734801598f..fd3ba4fc11 100644 --- a/github/util_rules.go +++ b/github/util_rules.go @@ -1,14 +1,24 @@ package github import ( + "context" "log" "reflect" "sort" "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) +type Target string + +const ( + TargetPush Target = "push" + TargetBranch Target = "branch" + TargetTag Target = "tag" +) + // This is a workaround for the SDK not setting the default value for the allowed_merge_methods field. var defaultPullRequestMergeMethods = []github.PullRequestMergeMethod{github.PullRequestMergeMethodMerge, github.PullRequestMergeMethodRebase, github.PullRequestMergeMethodSquash} @@ -221,19 +231,26 @@ func expandConditions(input []any, org bool) *github.RepositoryRulesetConditions } func flattenConditions(conditions *github.RepositoryRulesetConditions, org bool) []any { - if conditions == nil || conditions.RefName == nil { + return flattenConditionsWithContext(context.TODO(), conditions, org) +} + +func flattenConditionsWithContext(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 { @@ -566,7 +583,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) + log.Printf("[DEBUG] Flattened Pull Request rules slice: %#v", pullRequestSlice) rulesMap["pull_request"] = pullRequestSlice } diff --git a/github/util_rules_test.go b/github/util_rules_test.go index 9c09fa03d4..dc8d801df3 100644 --- a/github/util_rules_test.go +++ b/github/util_rules_test.go @@ -418,3 +418,155 @@ func TestCompletePushRulesetSupport(t *testing.T) { t.Errorf("Expected 3 restricted file extensions, got %d", len(restrictedExts)) } } + +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(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(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(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..9fb24e8582 --- /dev/null +++ b/github/util_ruleset_validation.go @@ -0,0 +1,183 @@ +package github + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// 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 = []string{ + "creation", + "update", + "deletion", + "required_linear_history", + "required_signatures", + "pull_request", + "required_status_checks", + "non_fast_forward", + "commit_message_pattern", + "commit_author_email_pattern", + "committer_email_pattern", + "branch_name_pattern", + "tag_name_pattern", + "required_workflows", + "required_code_scanning", + "required_deployments", + "merge_queue", +} + +// 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 = []string{ + "file_path_restriction", + "max_file_path_length", + "file_extension_restriction", + "max_file_size", +} + +func validateRulesForTarget(ctx context.Context, d *schema.ResourceDiff) error { + target := Target(d.Get("target").(string)) + tflog.Debug(ctx, "Validating rules for target", map[string]any{"target": target}) + + switch target { + case TargetPush: + return validateRulesForPushTarget(ctx, d) + case TargetBranch, TargetTag: + 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 []string) error { + target := Target(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 + } + 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, 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 := Target(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 TargetBranch, TargetTag: + return validateConditionsFieldForBranchAndTagTargets(ctx, target, conditions, isOrg) + case TargetPush: + return validateConditionsFieldForPushTarget(ctx, conditions) + } + return nil +} + +func validateRulesetRules(ctx context.Context, d *schema.ResourceDiff) error { + target := Target(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 Target, 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 errors.New("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..4b607a56cb --- /dev/null +++ b/github/util_ruleset_validation_test.go @@ -0,0 +1,225 @@ +package github + +import ( + "testing" +) + +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 Target + conditions map[string]any + expectError bool + errorMsg string + }{ + { + name: "valid branch target with ref_name", + target: TargetBranch, + 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: TargetTag, + 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: TargetBranch, + conditions: map[string]any{}, + expectError: true, + errorMsg: "ref_name must be set for branch target", + }, + { + name: "invalid tag target without ref_name", + target: TargetTag, + conditions: map[string]any{}, + expectError: true, + errorMsg: "ref_name must be set for tag target", + }, + { + name: "invalid branch target with nil ref_name", + target: TargetBranch, + 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: TargetTag, + 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 Target + conditions map[string]any + expectError bool + errorMsg string + }{ + { + name: "valid branch target with ref_name and repository_name", + target: TargetBranch, + 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: TargetTag, + 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: TargetBranch, + 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: TargetBranch, + 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: TargetTag, + 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: TargetBranch, + 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 e04118f678..12c228ee7e 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](#rules.branch_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](#rules.commit_author_email_pattern)) @@ -296,12 +297,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](#conditions.ref_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)) 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 61c5c733cb..911a1cafeb 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)) @@ -136,9 +138,9 @@ The `rules` block supports the following: * `required_code_scanning` - (Optional) (Block List, Max: 1) Define which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated. Multiple code scanning tools can be specified. (see [below for nested schema](#rulesrequired_code_scanning)) -* `file_path_restriction` - (Optional) (Block List, Max 1) Parameters to be used for the file_path_restriction rule. When enabled restricts access to files within the repository. (See [below for nested schema](#rules.file_path_restriction)) +* `file_path_restriction` - (Optional) (Block List, Max 1) Parameters to be used for the file_path_restriction rule. This rule only applies to rulesets with target `push`. (See [below for nested schema](#rules.file_path_restriction)) -* `max_file_size` - (Optional) (Block List, Max 1) Parameters to be used for the max_file_size rule. When enabled restricts the maximum size of a file that can be pushed to the repository. (See [below for nested schema](#rules.max_file_size)) +* `max_file_size` - (Optional) (Block List, Max 1) Parameters to be used for the max_file_size rule. This rule only applies to rulesets with target `push`. (See [below for nested schema](#rules.max_file_size)) * `max_file_path_length` - (Optional) (Block List, Max: 1) Prevent commits that include file paths that exceed a specified character limit from being pushed to the commit graph. This rule only applies to rulesets with target `push`. (see [below for nested schema](#rules.max_file_path_length)) @@ -289,7 +291,9 @@ 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](#conditions.ref_name)) + +~> **Note:** For `push` targets, do not include `ref_name` in conditions. Push rulesets operate on file content, not on refs. The `conditions` block is optional for push targets. #### conditions.ref_name ####