From 142296ebb87b3da757bf7a4015b0b5ffad18c417 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Tue, 21 Jul 2026 16:48:58 -0400 Subject: [PATCH 1/8] Add github_repository_environment_deployment_protection_rule resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables a GitHub App as a custom deployment protection rule on an environment (the deployment_protection_rule gate), which the provider previously could not express declaratively — resolves integrations/terraform-provider-github#2225. CRUD + import over go-github's Create/Get/DisableCustomDeploymentProtectionRule; schema is repository + environment + integration_id, mirroring the existing github_repository_environment_deployment_policy resource. Based on v6.13.0. --- ..._environment_deployment_protection_rule.md | 67 +++++++ github/provider.go | 1 + ..._environment_deployment_protection_rule.go | 167 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 docs/resources/repository_environment_deployment_protection_rule.md create mode 100644 github/resource_github_repository_environment_deployment_protection_rule.go diff --git a/docs/resources/repository_environment_deployment_protection_rule.md b/docs/resources/repository_environment_deployment_protection_rule.md new file mode 100644 index 0000000000..770f09a269 --- /dev/null +++ b/docs/resources/repository_environment_deployment_protection_rule.md @@ -0,0 +1,67 @@ +--- +page_title: "github_repository_environment_deployment_protection_rule (Resource) - GitHub" +description: |- + Creates and manages custom deployment protection rules for GitHub repository environments +--- + +# github_repository_environment_deployment_protection_rule (Resource) + +This resource enables a custom deployment protection rule — a GitHub App that gates +deployments to an environment — on a GitHub repository environment. When a workflow job +targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and +waits for the App to approve or reject the deployment. + +The App must be installed on the repository and available as a custom deployment +protection rule for the environment before it can be enabled. + +## Example Usage + +```terraform +resource "github_repository" "test" { + name = "tf-acc-test" +} + +resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "prod" +} + +resource "github_repository_environment_deployment_protection_rule" "test" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + integration_id = 4358979 # the GitHub App's ID +} +``` + +## Argument Reference + +The following arguments are supported: + +- `repository` - (Required) The name of the GitHub repository. + +- `environment` - (Required) The name of the environment. Changing this forces a new resource. + +- `integration_id` - (Required) The ID of the custom deployment protection rule integration — the GitHub App that gates deployments. Changing this forces a new resource. + +## Attributes Reference + +- `id` - An ID made of the repository name, environment name, and deployment protection rule ID, separated by `:`. + +## Import + +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and deployment protection rule ID, all separated by a `:`. + +### Import Block + +```terraform +import { + to = github_repository_environment_deployment_protection_rule.example + id = "myrepo:myenv:123456" +} +``` + +### Import Command + +```shell +terraform import github_repository_environment_deployment_protection_rule.example myrepo:myenv:123456 +``` diff --git a/github/provider.go b/github/provider.go index 24aede78c5..9e76e08a56 100644 --- a/github/provider.go +++ b/github/provider.go @@ -224,6 +224,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(), "github_repository_environment": resourceGithubRepositoryEnvironment(), "github_repository_environment_deployment_policy": resourceGithubRepositoryEnvironmentDeploymentPolicy(), + "github_repository_environment_deployment_protection_rule": resourceGithubRepositoryEnvironmentDeploymentProtectionRule(), "github_repository_file": resourceGithubRepositoryFile(), "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_pages": resourceGithubRepositoryPages(), diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go new file mode 100644 index 0000000000..fd18e02b60 --- /dev/null +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -0,0 +1,167 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "repository": { + Description: "The name of the GitHub repository.", + Type: schema.TypeString, + Required: true, + }, + "environment": { + Description: "The name of the environment.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "integration_id": { + Description: "The ID of the custom deployment protection rule integration — the GitHub App that gates deployments to the environment.", + Type: schema.TypeInt, + Required: true, + ForceNew: true, + }, + }, + + CustomizeDiff: customdiff.All( + diffRepository, + ), + + CreateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate, + ReadContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead, + DeleteContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport, + }, + } +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName := d.Get("repository").(string) + envName := d.Get("environment").(string) + integrationID := int64(d.Get("integration_id").(int)) + + rule, _, err := client.Repositories.CreateCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), &github.CustomDeploymentProtectionRuleRequest{ + IntegrationID: new(integrationID), + }) + if err != nil { + return diag.FromErr(err) + } + + id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(rule.GetID(), 10)) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + return resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx, d, m) +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid id (%s), expected format ::", d.Id())) + } + envName := unescapeIDPart(envNamePart) + ruleID, err := strconv.ParseInt(ruleIDStr, 10, 64) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid rule id: %s", ruleIDStr)) + } + + rule, _, err := client.Repositories.GetCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), ruleID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotModified { + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Custom deployment protection rule not found, removing from state.", map[string]any{"repository": repoName, "environment": envName, "rule_id": ruleID}) + d.SetId("") + return nil + } + } + return diag.FromErr(err) + } + + if err := d.Set("repository", repoName); err != nil { + return diag.FromErr(err) + } + if err := d.Set("environment", envName); err != nil { + return diag.FromErr(err) + } + if rule.App != nil { + if err := d.Set("integration_id", int(rule.App.GetID())); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid id (%s), expected format ::", d.Id())) + } + envName := unescapeIDPart(envNamePart) + ruleID, err := strconv.ParseInt(ruleIDStr, 10, 64) + if err != nil { + return diag.FromErr(fmt.Errorf("invalid rule id: %s", ruleIDStr)) + } + + _, err = client.Repositories.DisableCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), ruleID) + if err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid id (%s), expected format ::", d.Id()) + } + + if _, err := strconv.ParseInt(ruleIDStr, 10, 64); err != nil { + return nil, fmt.Errorf("invalid rule id: %s", ruleIDStr) + } + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + if err := d.Set("environment", unescapeIDPart(envNamePart)); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} From c6d66b08e341f2b9e947518d348a077c0ccd5663 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Wed, 22 Jul 2026 10:17:55 -0400 Subject: [PATCH 2/8] fix: repository must be ForceNew (no Update defined) InternalValidate rejects the resource: with no UpdateContext, every attribute must be ForceNew. repository was Required-only. Mark it ForceNew and drop the now-redundant diffRepository CustomizeDiff (it keys off a repository_id this resource doesn't have). `go test -run TestProvider` (InternalValidate) passes. --- ...hub_repository_environment_deployment_protection_rule.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go index fd18e02b60..5393de8213 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule.go +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -11,7 +11,6 @@ import ( "github.com/google/go-github/v88/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -22,6 +21,7 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resou Description: "The name of the GitHub repository.", Type: schema.TypeString, Required: true, + ForceNew: true, }, "environment": { Description: "The name of the environment.", @@ -37,10 +37,6 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resou }, }, - CustomizeDiff: customdiff.All( - diffRepository, - ), - CreateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate, ReadContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead, DeleteContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete, From 2b5bc5e971c918db36e3ad8909f04b5df56205e0 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Wed, 22 Jul 2026 10:22:31 -0400 Subject: [PATCH 3/8] fix: use go-github v89 in deployment protection rule resource main upgraded go-github v88 -> v89; the CreateCustomDeploymentProtectionRule request type moved packages. Match the rest of the provider. --- ..._github_repository_environment_deployment_protection_rule.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go index 5393de8213..7ec45fdfd9 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule.go +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -8,7 +8,7 @@ import ( "net/url" "strconv" - "github.com/google/go-github/v88/github" + "github.com/google/go-github/v89/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" From a84af47d5f7c15937a8578c5c3b465b708105c72 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Thu, 15 Oct 2026 16:13:24 -0400 Subject: [PATCH 4/8] fix: address review feedback on deployment protection rule resource - Add a resource Description for docs generation. - Add computed repository_id and use diffRepository so a repository rename updates the resource in place instead of replacing it; add an Update function that rebuilds the ID after a rename. - Add computed protection_rule_id and read repository, environment and rule ID from state rather than parsing the resource ID. - Set Computed fields in Create instead of calling Read. - Use errors.AsType[*github.ErrorResponse] for error checks. - Always set integration_id in Read so a stale value cannot stay in state. - Treat a 404 on delete as already deleted, so destroy succeeds when the rule, environment or repository is already gone. - Stop escaping the environment name: go-github v92 escapes it, so the extra url.PathEscape double-encoded names containing `/` or spaces. --- ..._environment_deployment_protection_rule.go | 142 +++++++++++------- 1 file changed, 89 insertions(+), 53 deletions(-) diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go index 7ec45fdfd9..590b9f1519 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule.go +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -5,10 +5,9 @@ import ( "errors" "fmt" "net/http" - "net/url" "strconv" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/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" @@ -16,12 +15,18 @@ import ( func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resource { return &schema.Resource{ + Description: "Manages a custom deployment protection rule for a GitHub repository environment. A custom deployment protection rule is a GitHub App that approves or rejects deployments to the environment.", + Schema: map[string]*schema.Schema{ "repository": { Description: "The name of the GitHub repository.", Type: schema.TypeString, Required: true, - ForceNew: true, + }, + "repository_id": { + Description: "The ID of the GitHub repository.", + Type: schema.TypeInt, + Computed: true, }, "environment": { Description: "The name of the environment.", @@ -30,15 +35,23 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resou ForceNew: true, }, "integration_id": { - Description: "The ID of the custom deployment protection rule integration — the GitHub App that gates deployments to the environment.", + Description: "The ID of the GitHub App to enable as a custom deployment protection rule. The App must be installed on the repository and available as a custom deployment protection rule for the environment.", Type: schema.TypeInt, Required: true, ForceNew: true, }, + "protection_rule_id": { + Description: "The ID of the custom deployment protection rule.", + Type: schema.TypeInt, + Computed: true, + }, }, + CustomizeDiff: diffRepository, + CreateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate, ReadContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead, + UpdateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate, DeleteContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport, @@ -47,16 +60,21 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resou } func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { - meta := m.(*Owner) + meta, _ := m.(*Owner) client := meta.v3client owner := meta.name - repoName := d.Get("repository").(string) - envName := d.Get("environment").(string) - integrationID := int64(d.Get("integration_id").(int)) + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + integrationID, _ := d.Get("integration_id").(int) - rule, _, err := client.Repositories.CreateCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), &github.CustomDeploymentProtectionRuleRequest{ - IntegrationID: new(integrationID), + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + + rule, _, err := client.Repositories.CreateCustomDeploymentProtectionRule(ctx, owner, repoName, envName, &github.CustomDeploymentProtectionRuleRequest{ + IntegrationID: new(int64(integrationID)), }) if err != nil { return diag.FromErr(err) @@ -68,74 +86,76 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate(ctx conte } d.SetId(id) - return resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx, d, m) + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.FromErr(err) + } + if err := d.Set("protection_rule_id", int(rule.GetID())); err != nil { + return diag.FromErr(err) + } + + return nil } func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { ctx = tflog.SetField(ctx, "id", d.Id()) - meta := m.(*Owner) + meta, _ := m.(*Owner) client := meta.v3client owner := meta.name - repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) - if err != nil { - return diag.FromErr(fmt.Errorf("invalid id (%s), expected format ::", d.Id())) - } - envName := unescapeIDPart(envNamePart) - ruleID, err := strconv.ParseInt(ruleIDStr, 10, 64) - if err != nil { - return diag.FromErr(fmt.Errorf("invalid rule id: %s", ruleIDStr)) - } + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) - rule, _, err := client.Repositories.GetCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), ruleID) + rule, _, err := client.Repositories.GetCustomDeploymentProtectionRule(ctx, owner, repoName, envName, int64(ruleID)) if err != nil { - var ghErr *github.ErrorResponse - if errors.As(err, &ghErr) { - if ghErr.Response.StatusCode == http.StatusNotModified { - return nil - } - if ghErr.Response.StatusCode == http.StatusNotFound { - tflog.Info(ctx, "Custom deployment protection rule not found, removing from state.", map[string]any{"repository": repoName, "environment": envName, "rule_id": ruleID}) - d.SetId("") - return nil - } + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Custom deployment protection rule not found, removing from state.", map[string]any{"repository": repoName, "environment": envName, "protection_rule_id": ruleID}) + d.SetId("") + return nil } return diag.FromErr(err) } - if err := d.Set("repository", repoName); err != nil { + if err := d.Set("integration_id", int(rule.GetApp().GetID())); err != nil { return diag.FromErr(err) } - if err := d.Set("environment", envName); err != nil { + + return nil +} + +// Update only runs for a repository rename (see diffRepository); every other argument forces a new resource. +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(_ context.Context, d *schema.ResourceData, _ any) diag.Diagnostics { + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) + + id, err := buildID(repoName, escapeIDPart(envName), strconv.Itoa(ruleID)) + if err != nil { return diag.FromErr(err) } - if rule.App != nil { - if err := d.Set("integration_id", int(rule.App.GetID())); err != nil { - return diag.FromErr(err) - } - } + d.SetId(id) return nil } func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { - meta := m.(*Owner) + ctx = tflog.SetField(ctx, "id", d.Id()) + + meta, _ := m.(*Owner) client := meta.v3client owner := meta.name - repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) - if err != nil { - return diag.FromErr(fmt.Errorf("invalid id (%s), expected format ::", d.Id())) - } - envName := unescapeIDPart(envNamePart) - ruleID, err := strconv.ParseInt(ruleIDStr, 10, 64) - if err != nil { - return diag.FromErr(fmt.Errorf("invalid rule id: %s", ruleIDStr)) - } + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) - _, err = client.Repositories.DisableCustomDeploymentProtectionRule(ctx, owner, repoName, url.PathEscape(envName), ruleID) + _, err := client.Repositories.DisableCustomDeploymentProtectionRule(ctx, owner, repoName, envName, int64(ruleID)) if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Custom deployment protection rule not found, treating it as deleted.", map[string]any{"repository": repoName, "environment": envName, "protection_rule_id": ruleID}) + return nil + } return diag.FromErr(err) } @@ -143,21 +163,37 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(ctx conte } func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) if err != nil { - return nil, fmt.Errorf("invalid id (%s), expected format ::", d.Id()) + return nil, fmt.Errorf("invalid id (%s), expected format ::", d.Id()) + } + + ruleID, err := strconv.Atoi(ruleIDStr) + if err != nil { + return nil, fmt.Errorf("invalid protection rule id: %s", ruleIDStr) } - if _, err := strconv.ParseInt(ruleIDStr, 10, 64); err != nil { - return nil, fmt.Errorf("invalid rule id: %s", ruleIDStr) + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) } if err := d.Set("repository", repoName); err != nil { return nil, err } + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return nil, err + } if err := d.Set("environment", unescapeIDPart(envNamePart)); err != nil { return nil, err } + if err := d.Set("protection_rule_id", ruleID); err != nil { + return nil, err + } return []*schema.ResourceData{d}, nil } From fbaaba11e85772a6990c6c034258ad356517d201 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Thu, 15 Oct 2026 16:13:24 -0400 Subject: [PATCH 5/8] test: add tests for deployment protection rule resource Add TestAccGithubRepositoryEnvironmentDeploymentProtectionRule covering create and delete (with a CheckDestroy that lists the environment's rules), import, repository rename and repository change. The tests need an App that can be enabled as a custom deployment protection rule, read from GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID, and skip without it. Add unit tests for Read and Delete against a mock API, covering 404 handling, the integration_id refresh and environment name escaping. --- .github/workflows/acceptance-tests.yaml | 1 + CONTRIBUTING.md | 4 + github/acc_test.go | 13 + ...ronment_deployment_protection_rule_test.go | 289 ++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 github/resource_github_repository_environment_deployment_protection_rule_test.go diff --git a/.github/workflows/acceptance-tests.yaml b/.github/workflows/acceptance-tests.yaml index 4bb4fbe3ac..2c439e97e1 100644 --- a/.github/workflows/acceptance-tests.yaml +++ b/.github/workflows/acceptance-tests.yaml @@ -227,6 +227,7 @@ jobs: GH_TEST_EXTERNAL_USER1_TOKEN: ${{ secrets.GH_TEST_EXTERNAL_USER1_TOKEN }} GH_TEST_EXTERNAL_USER2: ${{ vars.GH_TEST_EXTERNAL_USER2 }} GH_TEST_ADVANCED_SECURITY: ${{ vars.GH_TEST_ADVANCED_SECURITY || 'false' }} + GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID: ${{ vars.GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID }} run: | set -eou pipefail diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38e4a16246..240029d9d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -267,6 +267,9 @@ export GITHUB_ENTERPRISE_SLUG= # Configure test options export GH_TEST_ADVANCED_SECURITY= +# Configure the ID of a GitHub App, installed on the owner under test, that is available as a custom deployment protection rule +export GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID= + # Configure if the enterprise is an EMU enterprise export GH_TEST_ENTERPRISE_IS_EMU= ``` @@ -291,6 +294,7 @@ To run acceptance tests the `TF_ACC` environment variable must be set. Below is "GH_TEST_EXTERNAL_USER1_TOKEN": "", "GH_TEST_EXTERNAL_USER2": "", "GH_TEST_ADVANCED_SECURITY": "false", + "GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID": "", }, "go.testTimeout": "3600s", "go.testFlags": [ diff --git a/github/acc_test.go b/github/acc_test.go index 4b0729473b..a3bb302bed 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -81,6 +81,9 @@ type testAccConfig struct { // Test options testAdvancedSecurity bool + // ID of a GitHub App installed on the test owner that is available as a custom deployment protection rule + testDeploymentProtectionRuleAppID int + // Test repository configuration testRepositoryVisibility string @@ -173,6 +176,10 @@ func TestMain(m *testing.M) { } } + if i, err := strconv.Atoi(os.Getenv("GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID")); err == nil { + conf.testDeploymentProtectionRuleAppID = i + } + if conf.authMode != anonymous && conf.authMode != individual { if i, err := strconv.Atoi(os.Getenv("GH_TEST_ORG_APP_INSTALLATION_ID")); err == nil { conf.testOrgAppInstallationId = i @@ -395,6 +402,12 @@ func skipUnlessMode(t *testing.T, testModes ...testMode) { } } +func skipUnlessHasDeploymentProtectionRuleApp(t *testing.T) { + if testAccConf.testDeploymentProtectionRuleAppID == 0 { + t.Skip("Skipping as no deployment protection rule app is configured") + } +} + func skipUnlessHasOrgUser1(t *testing.T) { if testAccConf.testOrgUser1 == "" { t.Skip("Skipping as no test org user is configured") diff --git a/github/resource_github_repository_environment_deployment_protection_rule_test.go b/github/resource_github_repository_environment_deployment_protection_rule_test.go new file mode 100644 index 0000000000..364b03d0b8 --- /dev/null +++ b/github/resource_github_repository_environment_deployment_protection_rule_test.go @@ -0,0 +1,289 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubRepositoryEnvironmentDeploymentProtectionRule(t *testing.T) { + t.Parallel() + + skipUnauthenticated(t) + skipUnlessHasDeploymentProtectionRuleApp(t) + + config := ` +resource "github_repository_environment_deployment_protection_rule" "test" { + repository = "%s" + environment = "%s" + integration_id = %d +} +` + + t.Run("creates_and_deletes", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo, withTestCreateName("environment/test")) + appID := testAccConf.testDeploymentProtectionRuleAppID + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubRepositoryEnvironmentDeploymentProtectionRuleDestroy(repo.GetName(), env.GetName()), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("protection_rule_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("integration_id"), knownvalue.Int64Exact(int64(appID))), + }, + }, + }, + }) + }) + + t.Run("imports", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), testAccConf.testDeploymentProtectionRuleAppID), + }, + { + ResourceName: "github_repository_environment_deployment_protection_rule.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("updates_renamed_repo", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + newRepoName := fmt.Sprintf("%s-updated", repo.GetName()) + appID := testAccConf.testDeploymentProtectionRuleAppID + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + }, + { + PreConfig: func() { + mustRenameTestRepository(t, repo, newRepoName) + }, + Config: fmt.Sprintf(config, newRepoName, env.GetName(), appID), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_environment_deployment_protection_rule.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + }, + }, + }, + }) + }) + + t.Run("recreates_changed_repo", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + repo2 := mustCreateTestRepository(t) + _ = mustCreateTestRepositoryEnvironment(t, repo2, withTestCreateName(env.GetName())) + appID := testAccConf.testDeploymentProtectionRuleAppID + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + }, + { + Config: fmt.Sprintf(config, repo2.GetName(), env.GetName(), appID), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_environment_deployment_protection_rule.test", plancheck.ResourceActionReplace), + }, + }, + }, + }, + }) + }) +} + +func testAccCheckGithubRepositoryEnvironmentDeploymentProtectionRuleDestroy(repoName, envName string) resource.TestCheckFunc { + return func(_ *terraform.State) error { + meta := testAccConf.meta + rules, _, err := meta.v3client.Repositories.GetAllDeploymentProtectionRules(context.Background(), meta.name, repoName, envName) + if err != nil { + return err + } + if n := len(rules.ProtectionRules); n != 0 { + return fmt.Errorf("expected no custom deployment protection rules on %s/%s, found %d", repoName, envName, n) + } + return nil + } +} + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + repoName = "test-repo" + envName = "environment/test" + ruleID = 42 + ) + + ruleURI := fmt.Sprintf("/repos/%s/%s/environments/environment%%2Ftest/deployment_protection_rules/%d", owner, repoName, ruleID) + + for _, tt := range []struct { + name string + status int + body any + wantErr bool + wantRemoved bool + wantIntegrationID int + }{ + { + name: "sets integration_id from the rule's app", + status: http.StatusOK, + body: &github.CustomDeploymentProtectionRule{ID: new(int64(ruleID)), App: &github.CustomDeploymentProtectionRuleApp{ID: new(int64(1234))}}, + wantIntegrationID: 1234, + }, + { + name: "replaces a stale integration_id when the rule has no app", + status: http.StatusOK, + body: &github.CustomDeploymentProtectionRule{ID: new(int64(ruleID))}, + wantIntegrationID: 0, + }, + { + name: "removes the resource from state on 404", + status: http.StatusNotFound, + wantRemoved: true, + }, + { + name: "returns other errors", + status: http.StatusInternalServerError, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := githubApiMock([]*mockResponse{ + mustGetTestMockResponse(t, ruleURI, tt.status, tt.body), + }) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": repoName, + "environment": envName, + "integration_id": 9999, + "protection_rule_id": ruleID, + }) + d.SetId(fmt.Sprintf("%s:%s:%d", repoName, envName, ruleID)) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + if tt.wantErr { + return + } + if removed := d.Id() == ""; removed != tt.wantRemoved { + t.Fatalf("expected removed from state to be %v, got %v", tt.wantRemoved, removed) + } + if tt.wantRemoved { + return + } + if got, _ := d.Get("integration_id").(int); got != tt.wantIntegrationID { + t.Errorf("expected integration_id to be %d, got %d", tt.wantIntegrationID, got) + } + }) + } +} + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + repoName = "test-repo" + envName = "environment/test" + ruleID = 42 + ) + + ruleURI := fmt.Sprintf("/repos/%s/%s/environments/environment%%2Ftest/deployment_protection_rules/%d", owner, repoName, ruleID) + + for _, tt := range []struct { + name string + status int + wantErr bool + }{ + { + name: "disables the rule", + status: http.StatusNoContent, + }, + { + name: "treats 404 as already deleted", + status: http.StatusNotFound, + }, + { + name: "returns other errors", + status: http.StatusInternalServerError, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp := mustGetTestMockResponse(t, ruleURI, tt.status, nil) + resp.ExpectedMethod = http.MethodDelete + ts := githubApiMock([]*mockResponse{resp}) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": repoName, + "environment": envName, + "integration_id": 1234, + "protection_rule_id": ruleID, + }) + d.SetId(fmt.Sprintf("%s:%s:%d", repoName, envName, ruleID)) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + }) + } +} From 278f1c3b6d51604085f58cde308f2d564e1c429b Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Thu, 15 Oct 2026 16:13:24 -0400 Subject: [PATCH 6/8] docs: generate deployment protection rule docs from template Replace the hand-written page with a template and examples rendered by `make generatedocs`. --- ..._environment_deployment_protection_rule.md | 37 +++++++------- .../example_1.tf | 14 ++++++ .../example_2.tf | 4 ++ ...ronment_deployment_protection_rule.md.tmpl | 48 +++++++++++++++++++ 4 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 examples/resources/repository_environment_deployment_protection_rule/example_1.tf create mode 100644 examples/resources/repository_environment_deployment_protection_rule/example_2.tf create mode 100644 templates/resources/repository_environment_deployment_protection_rule.md.tmpl diff --git a/docs/resources/repository_environment_deployment_protection_rule.md b/docs/resources/repository_environment_deployment_protection_rule.md index 770f09a269..5a18abb13e 100644 --- a/docs/resources/repository_environment_deployment_protection_rule.md +++ b/docs/resources/repository_environment_deployment_protection_rule.md @@ -6,30 +6,26 @@ description: |- # github_repository_environment_deployment_protection_rule (Resource) -This resource enables a custom deployment protection rule — a GitHub App that gates -deployments to an environment — on a GitHub repository environment. When a workflow job -targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and -waits for the App to approve or reject the deployment. +Manages a custom deployment protection rule for a GitHub repository environment. A custom deployment protection rule is a GitHub App that approves or rejects deployments to the environment. -The App must be installed on the repository and available as a custom deployment -protection rule for the environment before it can be enabled. +When a workflow job targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and waits for the App to approve or reject the deployment. The App must be installed on the repository and available as a custom deployment protection rule for the environment before it can be enabled. ## Example Usage ```terraform -resource "github_repository" "test" { - name = "tf-acc-test" +resource "github_repository" "example" { + name = "example" } -resource "github_repository_environment" "test" { - repository = github_repository.test.name - environment = "prod" +resource "github_repository_environment" "example" { + repository = github_repository.example.name + environment = "production" } -resource "github_repository_environment_deployment_protection_rule" "test" { - repository = github_repository.test.name - environment = github_repository_environment.test.environment - integration_id = 4358979 # the GitHub App's ID +resource "github_repository_environment_deployment_protection_rule" "example" { + repository = github_repository.example.name + environment = github_repository_environment.example.environment + integration_id = 123456 # The ID of the GitHub App } ``` @@ -41,18 +37,21 @@ The following arguments are supported: - `environment` - (Required) The name of the environment. Changing this forces a new resource. -- `integration_id` - (Required) The ID of the custom deployment protection rule integration — the GitHub App that gates deployments. Changing this forces a new resource. +- `integration_id` - (Required) The ID of the GitHub App to enable as a custom deployment protection rule. Changing this forces a new resource. ## Attributes Reference -- `id` - An ID made of the repository name, environment name, and deployment protection rule ID, separated by `:`. +- `repository_id` - The ID of the repository. +- `protection_rule_id` - The ID of the custom deployment protection rule. ## Import -This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and deployment protection rule ID, all separated by a `:`. +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and custom deployment protection rule ID, all separated by a `:`. ### Import Block +The following import block imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + ```terraform import { to = github_repository_environment_deployment_protection_rule.example @@ -62,6 +61,8 @@ import { ### Import Command +The following command imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + ```shell terraform import github_repository_environment_deployment_protection_rule.example myrepo:myenv:123456 ``` diff --git a/examples/resources/repository_environment_deployment_protection_rule/example_1.tf b/examples/resources/repository_environment_deployment_protection_rule/example_1.tf new file mode 100644 index 0000000000..3d51fc66bf --- /dev/null +++ b/examples/resources/repository_environment_deployment_protection_rule/example_1.tf @@ -0,0 +1,14 @@ +resource "github_repository" "example" { + name = "example" +} + +resource "github_repository_environment" "example" { + repository = github_repository.example.name + environment = "production" +} + +resource "github_repository_environment_deployment_protection_rule" "example" { + repository = github_repository.example.name + environment = github_repository_environment.example.environment + integration_id = 123456 # The ID of the GitHub App +} diff --git a/examples/resources/repository_environment_deployment_protection_rule/example_2.tf b/examples/resources/repository_environment_deployment_protection_rule/example_2.tf new file mode 100644 index 0000000000..5470683b24 --- /dev/null +++ b/examples/resources/repository_environment_deployment_protection_rule/example_2.tf @@ -0,0 +1,4 @@ +import { + to = github_repository_environment_deployment_protection_rule.example + id = "myrepo:myenv:123456" +} diff --git a/templates/resources/repository_environment_deployment_protection_rule.md.tmpl b/templates/resources/repository_environment_deployment_protection_rule.md.tmpl new file mode 100644 index 0000000000..c5fa14629b --- /dev/null +++ b/templates/resources/repository_environment_deployment_protection_rule.md.tmpl @@ -0,0 +1,48 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Creates and manages custom deployment protection rules for GitHub repository environments +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +When a workflow job targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and waits for the App to approve or reject the deployment. The App must be installed on the repository and available as a custom deployment protection rule for the environment before it can be enabled. + +## Example Usage + +{{ tffile "examples/resources/repository_environment_deployment_protection_rule/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `repository` - (Required) The name of the GitHub repository. + +- `environment` - (Required) The name of the environment. Changing this forces a new resource. + +- `integration_id` - (Required) The ID of the GitHub App to enable as a custom deployment protection rule. Changing this forces a new resource. + +## Attributes Reference + +- `repository_id` - The ID of the repository. +- `protection_rule_id` - The ID of the custom deployment protection rule. + +## Import + +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and custom deployment protection rule ID, all separated by a `:`. + +### Import Block + +The following import block imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +{{tffile "examples/resources/repository_environment_deployment_protection_rule/example_2.tf"}} + +### Import Command + +The following command imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +```shell +terraform import github_repository_environment_deployment_protection_rule.example myrepo:myenv:123456 +``` From f2b5c5abe7eb979d59623235ab5e6d7683ddeb68 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Thu, 15 Oct 2026 16:28:28 -0400 Subject: [PATCH 7/8] test: discover the deployment protection rule app instead of configuring one The acceptance test lists the apps available as custom deployment protection rules on the test environment and skips when there are none, so it needs no new test configuration. --- .github/workflows/acceptance-tests.yaml | 1 - CONTRIBUTING.md | 4 --- github/acc_test.go | 13 --------- ...ronment_deployment_protection_rule_test.go | 29 +++++++++++++++---- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.github/workflows/acceptance-tests.yaml b/.github/workflows/acceptance-tests.yaml index 2c439e97e1..4bb4fbe3ac 100644 --- a/.github/workflows/acceptance-tests.yaml +++ b/.github/workflows/acceptance-tests.yaml @@ -227,7 +227,6 @@ jobs: GH_TEST_EXTERNAL_USER1_TOKEN: ${{ secrets.GH_TEST_EXTERNAL_USER1_TOKEN }} GH_TEST_EXTERNAL_USER2: ${{ vars.GH_TEST_EXTERNAL_USER2 }} GH_TEST_ADVANCED_SECURITY: ${{ vars.GH_TEST_ADVANCED_SECURITY || 'false' }} - GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID: ${{ vars.GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID }} run: | set -eou pipefail diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 240029d9d2..38e4a16246 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -267,9 +267,6 @@ export GITHUB_ENTERPRISE_SLUG= # Configure test options export GH_TEST_ADVANCED_SECURITY= -# Configure the ID of a GitHub App, installed on the owner under test, that is available as a custom deployment protection rule -export GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID= - # Configure if the enterprise is an EMU enterprise export GH_TEST_ENTERPRISE_IS_EMU= ``` @@ -294,7 +291,6 @@ To run acceptance tests the `TF_ACC` environment variable must be set. Below is "GH_TEST_EXTERNAL_USER1_TOKEN": "", "GH_TEST_EXTERNAL_USER2": "", "GH_TEST_ADVANCED_SECURITY": "false", - "GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID": "", }, "go.testTimeout": "3600s", "go.testFlags": [ diff --git a/github/acc_test.go b/github/acc_test.go index a3bb302bed..4b0729473b 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -81,9 +81,6 @@ type testAccConfig struct { // Test options testAdvancedSecurity bool - // ID of a GitHub App installed on the test owner that is available as a custom deployment protection rule - testDeploymentProtectionRuleAppID int - // Test repository configuration testRepositoryVisibility string @@ -176,10 +173,6 @@ func TestMain(m *testing.M) { } } - if i, err := strconv.Atoi(os.Getenv("GH_TEST_DEPLOYMENT_PROTECTION_RULE_APP_ID")); err == nil { - conf.testDeploymentProtectionRuleAppID = i - } - if conf.authMode != anonymous && conf.authMode != individual { if i, err := strconv.Atoi(os.Getenv("GH_TEST_ORG_APP_INSTALLATION_ID")); err == nil { conf.testOrgAppInstallationId = i @@ -402,12 +395,6 @@ func skipUnlessMode(t *testing.T, testModes ...testMode) { } } -func skipUnlessHasDeploymentProtectionRuleApp(t *testing.T) { - if testAccConf.testDeploymentProtectionRuleAppID == 0 { - t.Skip("Skipping as no deployment protection rule app is configured") - } -} - func skipUnlessHasOrgUser1(t *testing.T) { if testAccConf.testOrgUser1 == "" { t.Skip("Skipping as no test org user is configured") diff --git a/github/resource_github_repository_environment_deployment_protection_rule_test.go b/github/resource_github_repository_environment_deployment_protection_rule_test.go index 364b03d0b8..636e3446a5 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule_test.go +++ b/github/resource_github_repository_environment_deployment_protection_rule_test.go @@ -20,7 +20,6 @@ func TestAccGithubRepositoryEnvironmentDeploymentProtectionRule(t *testing.T) { t.Parallel() skipUnauthenticated(t) - skipUnlessHasDeploymentProtectionRuleApp(t) config := ` resource "github_repository_environment_deployment_protection_rule" "test" { @@ -35,7 +34,7 @@ resource "github_repository_environment_deployment_protection_rule" "test" { repo := mustCreateTestRepository(t) env := mustCreateTestRepositoryEnvironment(t, repo, withTestCreateName("environment/test")) - appID := testAccConf.testDeploymentProtectionRuleAppID + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, @@ -46,7 +45,7 @@ resource "github_repository_environment_deployment_protection_rule" "test" { ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("protection_rule_id"), knownvalue.NotNull()), - statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("integration_id"), knownvalue.Int64Exact(int64(appID))), + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("integration_id"), knownvalue.Int64Exact(appID)), }, }, }, @@ -58,12 +57,13 @@ resource "github_repository_environment_deployment_protection_rule" "test" { repo := mustCreateTestRepository(t) env := mustCreateTestRepositoryEnvironment(t, repo) + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), testAccConf.testDeploymentProtectionRuleAppID), + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), }, { ResourceName: "github_repository_environment_deployment_protection_rule.test", @@ -80,7 +80,7 @@ resource "github_repository_environment_deployment_protection_rule" "test" { repo := mustCreateTestRepository(t) env := mustCreateTestRepositoryEnvironment(t, repo) newRepoName := fmt.Sprintf("%s-updated", repo.GetName()) - appID := testAccConf.testDeploymentProtectionRuleAppID + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, @@ -113,7 +113,7 @@ resource "github_repository_environment_deployment_protection_rule" "test" { env := mustCreateTestRepositoryEnvironment(t, repo) repo2 := mustCreateTestRepository(t) _ = mustCreateTestRepositoryEnvironment(t, repo2, withTestCreateName(env.GetName())) - appID := testAccConf.testDeploymentProtectionRuleAppID + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, @@ -134,6 +134,23 @@ resource "github_repository_environment_deployment_protection_rule" "test" { }) } +// mustFindDeploymentProtectionRuleApp returns the first app the test owner can use as a custom deployment +// protection rule on the environment, and skips the test when there is none. +func mustFindDeploymentProtectionRuleApp(t *testing.T, repo *github.Repository, env *github.Environment) int64 { + t.Helper() + + meta := testAccConf.meta + res, _, err := meta.v3client.Repositories.ListCustomDeploymentRuleIntegrations(context.Background(), meta.name, repo.GetName(), env.GetName(), nil) + if err != nil { + t.Fatalf("failed to list custom deployment protection rule integrations: %v", err) + } + if len(res.AvailableIntegrations) == 0 { + t.Skip("Skipping as no app is available as a custom deployment protection rule") + } + + return res.AvailableIntegrations[0].GetID() +} + func testAccCheckGithubRepositoryEnvironmentDeploymentProtectionRuleDestroy(repoName, envName string) resource.TestCheckFunc { return func(_ *terraform.State) error { meta := testAccConf.meta From c732a380c2faf721194d6137ad890b1b8d2eb5e0 Mon Sep 17 00:00:00 2001 From: Aaron Stockmeister Date: Thu, 15 Oct 2026 16:45:06 -0400 Subject: [PATCH 8/8] fix: check the repository on update and validate integration_id Update only rewrote the ID, so a repository change that diffRepository could not resolve at plan time applied as a no-op and left the rule on the old repository. Update now checks the new name still resolves to repository_id. Also validate integration_id, skip the acceptance tests when the test credentials cannot list protection rule apps, and list the resource in RESOURCES.md. --- RESOURCES.md | 1 + ..._environment_deployment_protection_rule.go | 28 +++++-- ...ronment_deployment_protection_rule_test.go | 81 ++++++++++++++++++- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/RESOURCES.md b/RESOURCES.md index b1b1c3e2b0..494a7435c9 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -193,6 +193,7 @@ The overall status of each resource or data source is captured in this document | `github_repository_deployment_branch_policy` (🚫) | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_environment` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_environment_deployment_policy` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_repository_environment_deployment_protection_rule` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_file` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_milestone` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_pages` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go index 590b9f1519..0804e0984d 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule.go +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -11,6 +11,7 @@ import ( "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 resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resource { @@ -35,10 +36,11 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resou ForceNew: true, }, "integration_id": { - Description: "The ID of the GitHub App to enable as a custom deployment protection rule. The App must be installed on the repository and available as a custom deployment protection rule for the environment.", - Type: schema.TypeInt, - Required: true, - ForceNew: true, + Description: "The ID of the GitHub App to enable as a custom deployment protection rule. The App must be installed on the repository and available as a custom deployment protection rule for the environment.", + Type: schema.TypeInt, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.IntAtLeast(1)), }, "protection_rule_id": { Description: "The ID of the custom deployment protection rule.", @@ -124,11 +126,25 @@ func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx context return nil } -// Update only runs for a repository rename (see diffRepository); every other argument forces a new resource. -func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(_ context.Context, d *schema.ResourceData, _ any) diag.Diagnostics { +// Update only runs when repository changes without diffRepository forcing a new resource. That is a rename, or a +// repository diffRepository could not look up at plan time, so check the new name is still the same repository. +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + repoName, _ := d.Get("repository").(string) envName, _ := d.Get("environment").(string) ruleID, _ := d.Get("protection_rule_id").(int) + repoID, _ := d.Get("repository_id").(int) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + if repo.GetID() != int64(repoID) { + return diag.Errorf("repository %q (ID %d) is not the repository this protection rule was created in (ID %d); replace the resource to move the rule to another repository", repoName, repo.GetID(), repoID) + } id, err := buildID(repoName, escapeIDPart(envName), strconv.Itoa(ruleID)) if err != nil { diff --git a/github/resource_github_repository_environment_deployment_protection_rule_test.go b/github/resource_github_repository_environment_deployment_protection_rule_test.go index 636e3446a5..758f4d75bb 100644 --- a/github/resource_github_repository_environment_deployment_protection_rule_test.go +++ b/github/resource_github_repository_environment_deployment_protection_rule_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "net/http" "testing" @@ -140,7 +141,10 @@ func mustFindDeploymentProtectionRuleApp(t *testing.T, repo *github.Repository, t.Helper() meta := testAccConf.meta - res, _, err := meta.v3client.Repositories.ListCustomDeploymentRuleIntegrations(context.Background(), meta.name, repo.GetName(), env.GetName(), nil) + res, _, err := meta.v3client.Repositories.ListCustomDeploymentRuleIntegrations(t.Context(), meta.name, repo.GetName(), env.GetName(), nil) + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusForbidden { + t.Skip("Skipping as the test credentials cannot list custom deployment protection rule apps") + } if err != nil { t.Fatalf("failed to list custom deployment protection rule integrations: %v", err) } @@ -304,3 +308,78 @@ func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(t *t }) } } + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + oldRepoName = "test-repo" + newRepoName = "test-repo-renamed" + envName = "environment/test" + repoID = 100 + ruleID = 42 + ) + + repoURI := fmt.Sprintf("/repos/%s/%s", owner, newRepoName) + + for _, tt := range []struct { + name string + status int + body any + wantErr bool + }{ + { + name: "updates the ID after a rename", + status: http.StatusOK, + body: &github.Repository{ID: new(int64(repoID)), Name: new(newRepoName)}, + }, + { + name: "errors when the name now belongs to another repository", + status: http.StatusOK, + body: &github.Repository{ID: new(int64(repoID + 1)), Name: new(newRepoName)}, + wantErr: true, + }, + { + name: "errors when the repository does not exist", + status: http.StatusNotFound, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := githubApiMock([]*mockResponse{ + mustGetTestMockResponse(t, repoURI, tt.status, tt.body), + }) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": newRepoName, + "repository_id": repoID, + "environment": envName, + "integration_id": 1234, + "protection_rule_id": ruleID, + }) + oldID := fmt.Sprintf("%s:%s:%d", oldRepoName, envName, ruleID) + d.SetId(oldID) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + if tt.wantErr { + if d.Id() != oldID { + t.Errorf("expected ID to stay %q on error, got %q", oldID, d.Id()) + } + return + } + if want := fmt.Sprintf("%s:%s:%d", newRepoName, escapeIDPart(envName), ruleID); d.Id() != want { + t.Errorf("expected ID to be %q, got %q", want, d.Id()) + } + }) + } +}