Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
9909ecc
Ensure tests use `GITHUB_TEST_*` values for Owner and Organization
deiga Dec 2, 2025
d3bafe6
Update `skipUnlessMode` to check for enterprise ENV variables
deiga Dec 2, 2025
a4f929b
Use `testOwnerFunc` to set `testOwner` so that `GITHUB_TEST_OWNER` wo…
deiga Dec 3, 2025
fbc8707
Switch from `NewSubsystemLoggingHTTPTransport` to `NewLoggingHTTPTran…
deiga Dec 3, 2025
6966690
Update to use Context aware CRUD functions
deiga Dec 7, 2025
a60ac7f
Ensure `update_allows_fetch_and_merge` isn't added for Org Ruleset
deiga Dec 4, 2025
3e2cd38
Update name of resource, to make debugging failing tests easier
deiga Dec 4, 2025
d82c614
Need to have one of `repository_name` or `repository_id` defined
deiga Dec 4, 2025
b2c3570
Need to have a valid repo reference
deiga Dec 4, 2025
6954994
Actions repository access level needs to be set properly
deiga Dec 4, 2025
a41b2f3
Required workflow needs to be in the `.github/workflows` folder
deiga Dec 4, 2025
41b3dec
Update indentation
deiga Dec 4, 2025
512db50
Need to have one of `repository_name` or `repository_id` defined
deiga Dec 4, 2025
bdc4729
Add handling of `AllowedMergeMethods`
deiga Dec 4, 2025
405d3b6
Add `allowed_merge_methods` to `rules` for Org & Repo rulesets
deiga Dec 6, 2025
986ae22
Fixed type conversion for `allowed_merge_methods`
deiga Dec 6, 2025
581ff8c
Update test content to actually pass the GH API
deiga Dec 4, 2025
e97f75c
Convert `github_repository` to use `*Context` CRUD methods
deiga Dec 6, 2025
f096f44
Enable `TestGithubOrganizationRulesets/Creates_and_updates_organizati…
deiga Dec 6, 2025
2d77c1e
`make fmt`
deiga Dec 7, 2025
9d22505
Add workaround for GH API bug that OrgAdmin actor_id is returned as `…
deiga Dec 6, 2025
74bcfe7
`make fmt`
deiga Dec 7, 2025
4808483
Fix `TestGithubOrganizationRulesets/Creates_organization_ruleset_with…
deiga Dec 7, 2025
f698cd2
Fix tests regarding `bypass_actors` ordering
deiga Dec 7, 2025
3752c16
Rename test resources for easier debugging
deiga Dec 7, 2025
cf35bf9
Update descriptions and validations
deiga Nov 27, 2025
35ff668
Add `CustomizeDiff` logic to validate on `plan`
deiga Nov 27, 2025
a944da8
Allow `organization` in tests as well
deiga Nov 29, 2025
e471a58
Remove unused leftover
deiga Nov 29, 2025
92fc538
Add first validation test
deiga Nov 29, 2025
4f2ea6a
Update formatting
deiga Nov 30, 2025
0e81c85
Add validation error when `conditions` is missing
deiga Nov 30, 2025
056a70f
Add further validation tests
deiga Nov 30, 2025
740c5e2
Remove unnecessary skip blocks as `individual` and `anonymous` access…
deiga Dec 2, 2025
4fc2f51
Switch `ref_name` to `Optional` as `push` doesn't need a `ref_name`
deiga Dec 2, 2025
a70831e
Add Debug logging with `tflog` to validation
deiga Dec 3, 2025
9d85734
Fix validation as `ref_name`, `repository_name` and `repository_id` a…
deiga Dec 3, 2025
5b88bb2
Correctly use `enterprise` runner
deiga Dec 3, 2025
08a73fc
Fix test output expectation
deiga Dec 3, 2025
3f34fb6
Remove unnecessary panic test
deiga Dec 3, 2025
776fc64
Improve validation output messages
deiga Dec 3, 2025
7f65508
Update to use Context aware CRUD functions
deiga Dec 3, 2025
2479b73
Fix condition to require only one of `repository_name` or `repository…
deiga Dec 3, 2025
4666bbe
Add validation to `required_workflow.path`
deiga Dec 6, 2025
e9c10b4
`make fmt`
deiga Dec 6, 2025
519ea54
Rename test resources for easier debugging
deiga Dec 7, 2025
21e2e02
Fix linter issues
deiga Dec 7, 2025
77e8cfe
Add validation to ensure `rules.required_status_checks.required_check…
deiga Dec 8, 2025
6782aab
`go mod tidy`
deiga Dec 8, 2025
97e2351
Add test to ensure that `required_checks` is always required
deiga Dec 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion github/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ var GHECDataResidencyHostMatch = regexp.MustCompile(`^[a-zA-Z0-9.\-]+\.ghe\.com\
func RateLimitedHTTPClient(client *http.Client, writeDelay, readDelay, retryDelay time.Duration, parallelRequests bool, retryableErrors map[int]bool, maxRetries int) *http.Client {
client.Transport = NewEtagTransport(client.Transport)
client.Transport = NewRateLimitTransport(client.Transport, WithWriteDelay(writeDelay), WithReadDelay(readDelay), WithParallelRequests(parallelRequests))
client.Transport = logging.NewSubsystemLoggingHTTPTransport("GitHub", client.Transport)
client.Transport = logging.NewLoggingHTTPTransport(client.Transport)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain why we need this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't inititialize the GitHub subsystem anywhere and from my understanding subsystems are usually per-resource things.
With an unitialized Subsystem the logs get spammed with warnings about it.

But this implementation is "actually" in this PR #2976 and any modifications to it should happen there

client.Transport = newPreviewHeaderInjectorTransport(map[string]string{
// TODO: remove when Stone Crop preview is moved to general availability in the GraphQL API
"Accept": "application/vnd.github.stone-crop-preview+json",
Expand Down
12 changes: 7 additions & 5 deletions github/provider_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var (
isPaidPlan = os.Getenv("GITHUB_PAID_FEATURES")
testEnterprise = os.Getenv("ENTERPRISE_SLUG")
testOrganization = testOrganizationFunc()
testOwner = os.Getenv("GITHUB_OWNER")
testOwner = testOwnerFunc()
testToken = os.Getenv("GITHUB_TOKEN")
testBaseURLGHES = os.Getenv("GHES_BASE_URL")
)
Expand Down Expand Up @@ -54,8 +54,8 @@ func skipUnlessMode(t *testing.T, providerMode string) {
t.Log("GITHUB_TOKEN environment variable should be empty")
}
case enterprise:
if os.Getenv("GITHUB_TOKEN") == "" {
t.Log("GITHUB_TOKEN environment variable should be set")
if os.Getenv("GITHUB_TOKEN") == "" || os.Getenv("ENTERPRISE_ACCOUNT") != "true" || os.Getenv("ENTERPRISE_SLUG") == "" {
t.Log("GITHUB_TOKEN and ENTERPRISE_ACCOUNT and ENTERPRISE_SLUG environment variables should be set")
} else {
return
}
Expand All @@ -67,10 +67,10 @@ func skipUnlessMode(t *testing.T, providerMode string) {
t.Log("GITHUB_TOKEN and GITHUB_OWNER environment variables should be set")
}
case organization:
if os.Getenv("GITHUB_TOKEN") != "" && os.Getenv("GITHUB_ORGANIZATION") != "" {
if os.Getenv("GITHUB_TOKEN") != "" && (os.Getenv("GITHUB_ORGANIZATION") != "" || os.Getenv("GITHUB_TEST_ORGANIZATION") != "") {
return
} else {
t.Log("GITHUB_TOKEN and GITHUB_ORGANIZATION environment variables should be set")
t.Log("GITHUB_TOKEN and GITHUB_ORGANIZATION or GITHUB_TEST_ORGANIZATION environment variables should be set")
}
}

Expand Down Expand Up @@ -120,6 +120,7 @@ func testOrganizationFunc() string {
organization := os.Getenv("GITHUB_ORGANIZATION")
if organization == "" {
organization = os.Getenv("GITHUB_TEST_ORGANIZATION")
os.Setenv("GITHUB_ORGANIZATION", organization)
}
return organization
}
Expand All @@ -128,6 +129,7 @@ func testOwnerFunc() string {
owner := os.Getenv("GITHUB_OWNER")
if owner == "" {
owner = os.Getenv("GITHUB_TEST_OWNER")
os.Setenv("GITHUB_OWNER", owner)
}
return owner
}
Expand Down
144 changes: 109 additions & 35 deletions github/resource_github_organization_ruleset.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,30 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strconv"

"github.com/google/go-github/v77/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"
)

func resourceGithubOrganizationRuleset() *schema.Resource {
return &schema.Resource{
Create: resourceGithubOrganizationRulesetCreate,
Read: resourceGithubOrganizationRulesetRead,
Update: resourceGithubOrganizationRulesetUpdate,
Delete: resourceGithubOrganizationRulesetDelete,
CreateContext: resourceGithubOrganizationRulesetCreate,
ReadContext: resourceGithubOrganizationRulesetRead,
UpdateContext: resourceGithubOrganizationRulesetUpdate,
DeleteContext: resourceGithubOrganizationRulesetDelete,
Importer: &schema.ResourceImporter{
State: resourceGithubOrganizationRulesetImport,
StateContext: resourceGithubOrganizationRulesetImport,
},

SchemaVersion: 1,

CustomizeDiff: validateConditionsFieldBasedOnTarget,

Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Expand All @@ -35,8 +40,8 @@ func resourceGithubOrganizationRuleset() *schema.Resource {
"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.",
ValidateFunc: validation.StringInSlice([]string{"branch", "tag", "push", "repository"}, false),
Description: "The target of the ruleset. Possible values are `branch`, `tag`, `push` and `repository`.",
},
"enforcement": {
Type: schema.TypeString,
Expand All @@ -45,7 +50,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource {
Description: "Possible values for Enforcement are `disabled`, `active`, `evaluate`. Note: `evaluate` is currently only supported for owners of type `organization`.",
},
"bypass_actors": {
Type: schema.TypeList,
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should sort the returned values based on the inputs which would stop churn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds sensible, need to investigate where the best place for the sorting is

Optional: true,
DiffSuppressFunc: bypassActorsDiffSuppressFunc,
Description: "The actors that can bypass the rules in this ruleset.",
Expand Down Expand Up @@ -86,12 +91,12 @@ 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. For `branch` and `tag` targets, `ref_name` is required alongside one of `repository_name` or `repository_id`. The `push` target does not require `ref_name` conditions. For `repository` target, the conditions object should only contain the `repository_name` and the `repository_id`.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"ref_name": {
Type: schema.TypeList,
Required: true,
Optional: true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a non breaking change ➕

MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
Expand Down Expand Up @@ -197,6 +202,16 @@ func resourceGithubOrganizationRuleset() *schema.Resource {
Description: "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"allowed_merge_methods": {
Type: schema.TypeList,
Required: true,
MinItems: 1,
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"),
},
},
"dismiss_stale_reviews_on_push": {
Type: schema.TypeBool,
Optional: true,
Expand Down Expand Up @@ -245,9 +260,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,
Expand Down Expand Up @@ -454,9 +470,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,
Expand Down Expand Up @@ -585,36 +602,35 @@ func resourceGithubOrganizationRuleset() *schema.Resource {
}
}

func resourceGithubOrganizationRulesetCreate(d *schema.ResourceData, meta any) error {
func resourceGithubOrganizationRulesetCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*Owner).v3client

rulesetReq := resourceGithubRulesetObject(d, meta.(*Owner).name)

org := meta.(*Owner).name
ctx := context.Background()

var ruleset *github.RepositoryRuleset
var err error

ruleset, _, err = client.Organizations.CreateRepositoryRuleset(ctx, org, *rulesetReq)
if err != nil {
return err
return diag.FromErr(err)
}
d.SetId(strconv.FormatInt(*ruleset.ID, 10))

return resourceGithubOrganizationRulesetRead(d, meta)
return resourceGithubOrganizationRulesetRead(ctx, d, meta)
}

func resourceGithubOrganizationRulesetRead(d *schema.ResourceData, meta any) error {
func resourceGithubOrganizationRulesetRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*Owner).v3client

org := meta.(*Owner).name
rulesetID, err := strconv.ParseInt(d.Id(), 10, 64)
if err != nil {
return unconvertibleIdErr(d.Id(), err)
return diag.FromErr(unconvertibleIdErr(d.Id(), err))
}

ctx := context.WithValue(context.Background(), ctxId, rulesetID)
ctx = context.WithValue(ctx, ctxId, rulesetID)
if !d.IsNewResource() {
ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string))
}
Expand All @@ -636,7 +652,7 @@ func resourceGithubOrganizationRulesetRead(d *schema.ResourceData, meta any) err
return nil
}
}
return err
return diag.FromErr(err)
}

if ruleset == nil {
Expand All @@ -659,46 +675,46 @@ func resourceGithubOrganizationRulesetRead(d *schema.ResourceData, meta any) err
return nil
}

func resourceGithubOrganizationRulesetUpdate(d *schema.ResourceData, meta any) error {
func resourceGithubOrganizationRulesetUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*Owner).v3client

rulesetReq := resourceGithubRulesetObject(d, meta.(*Owner).name)

org := meta.(*Owner).name
rulesetID, err := strconv.ParseInt(d.Id(), 10, 64)
if err != nil {
return unconvertibleIdErr(d.Id(), err)
return diag.FromErr(unconvertibleIdErr(d.Id(), err))
}

ctx := context.WithValue(context.Background(), ctxId, rulesetID)
ctx = context.WithValue(ctx, ctxId, rulesetID)

var ruleset *github.RepositoryRuleset

ruleset, _, err = client.Organizations.UpdateRepositoryRuleset(ctx, org, rulesetID, *rulesetReq)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking if the bypass actors have changed and the length is 0 here would allow you to use the clear function to resolve #2952. See #2891 (comment).

if err != nil {
return err
return diag.FromErr(err)
}
d.SetId(strconv.FormatInt(*ruleset.ID, 10))

return resourceGithubOrganizationRulesetRead(d, meta)
return resourceGithubOrganizationRulesetRead(ctx, d, meta)
}

func resourceGithubOrganizationRulesetDelete(d *schema.ResourceData, meta any) error {
func resourceGithubOrganizationRulesetDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*Owner).v3client
org := meta.(*Owner).name

rulesetID, err := strconv.ParseInt(d.Id(), 10, 64)
if err != nil {
return unconvertibleIdErr(d.Id(), err)
return diag.FromErr(unconvertibleIdErr(d.Id(), err))
}
ctx := context.WithValue(context.Background(), ctxId, rulesetID)
ctx = context.WithValue(ctx, ctxId, rulesetID)

log.Printf("[DEBUG] Deleting organization ruleset: %s: %d", org, rulesetID)
_, err = client.Organizations.DeleteRepositoryRuleset(ctx, org, rulesetID)
return err
return diag.FromErr(err)
}

func resourceGithubOrganizationRulesetImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) {
func resourceGithubOrganizationRulesetImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) {
rulesetID, err := strconv.ParseInt(d.Id(), 10, 64)
if err != nil {
return []*schema.ResourceData{d}, unconvertibleIdErr(d.Id(), err)
Expand All @@ -710,7 +726,6 @@ func resourceGithubOrganizationRulesetImport(d *schema.ResourceData, meta any) (

client := meta.(*Owner).v3client
org := meta.(*Owner).name
ctx := context.Background()

ruleset, _, err := client.Organizations.GetRepositoryRuleset(ctx, org, rulesetID)
if ruleset == nil || err != nil {
Expand All @@ -720,3 +735,62 @@ func resourceGithubOrganizationRulesetImport(d *schema.ResourceData, meta any) (

return []*schema.ResourceData{d}, nil
}

func validateConditionsFieldForBranchAndTagTargets(ctx context.Context, d *schema.ResourceDiff, _ any) error {
target := d.Get("target").(string)
conditions := d.Get("conditions").([]any)[0].(map[string]any)
tflog.Debug(ctx, "Validating conditions field for branch and tag targets", map[string]any{"target": target, "conditions": conditions})
if conditions["ref_name"] == nil || len(conditions["ref_name"].([]any)) == 0 {
return fmt.Errorf("ref_name must be set for %s target", target)
}
if (conditions["repository_name"] == nil || len(conditions["repository_name"].([]any)) == 0) && (conditions["repository_id"] == nil || len(conditions["repository_id"].([]any)) == 0) {
return fmt.Errorf("either repository_name or repository_id must be set for %s target", target)
}
return nil
}

func validateConditionsFieldForPushTarget(ctx context.Context, d *schema.ResourceDiff, _ any) error {
target := d.Get("target").(string)
conditions := d.Get("conditions").([]any)[0].(map[string]any)
tflog.Debug(ctx, "Validating conditions field for push target", map[string]any{"target": target, "conditions": conditions})

if conditions["ref_name"] != nil && len(conditions["ref_name"].([]any)) > 0 {
return fmt.Errorf("ref_name must not be set for %s target", target)
}
return nil
}

func validateConditionsFieldForRepositoryTarget(ctx context.Context, d *schema.ResourceDiff, _ any) error {
target := d.Get("target").(string)
conditions := d.Get("conditions").([]any)[0].(map[string]any)
tflog.Debug(ctx, "Validating conditions field for repository target", map[string]any{"target": target, "conditions": conditions})

if conditions["ref_name"] != nil && len(conditions["ref_name"].([]any)) > 0 {
return fmt.Errorf("ref_name must not be set for %s target", target)
}
if conditions["repository_name"] == nil || len(conditions["repository_name"].([]any)) == 0 || conditions["repository_id"] == nil || len(conditions["repository_id"].([]any)) == 0 {
return fmt.Errorf("repository_name or repository_id must be set for %s target", target)
}
return nil
}

func validateConditionsFieldBasedOnTarget(ctx context.Context, d *schema.ResourceDiff, meta any) error {
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
}

switch target {
case "branch", "tag":
return validateConditionsFieldForBranchAndTagTargets(ctx, d, meta)
case "push":
return validateConditionsFieldForPushTarget(ctx, d, meta)
case "repository":
return validateConditionsFieldForRepositoryTarget(ctx, d, meta)
}
return nil
}
Loading