This document serves as a guide for contributors implementing new features and resources in the Terraform Provider for GitHub.
- Module Map
- Core Principles
- Resource Design
- Implementation Patterns
- Testing
- Gotchas & Known Issues
- Appendix
terraform-provider-github/
├── github/
│ ├── provider.go # Entry point, registers all resources/data sources
│ ├── config.go # Auth setup, HTTP client, rate limiting, transport
│ │
│ ├── resource_github_*.go # Resource implementations
│ ├── resource_*_migration.go # Resource state migration functions (StateUpgraders)
│ ├── data_source_github_*.go # Data source implementations
│ │
│ │
│ ├── util.go # Core utilities (ID parsing, validation)
│ ├── util_*.go # Domain utilities (rules, labels, etc.)
│ │
│ └── transport.go # HTTP transports: ETag caching, rate limiting, retries
│
├── ARCHITECTURE.md # This file - implementation guide
├── MAINTAINERS.md # Maintainers and contributors
└── CONTRIBUTING.md # How to contribute
Each Terraform resource should map to a single GitHub API entity. Avoid creating resources that combine multiple API concerns.
Do:
// Manages a single repository
func resourceGithubRepository() *schema.Resource { ... }
// Manages repository topics separately
func resourceGithubRepositoryTopics() *schema.Resource { ... }Don't:
// Don't combine unrelated API entities into one resource
func resourceGithubRepositoryWithTopicsAndLabels() *schema.Resource { ... }Each resource should use the minimum number of API calls necessary. Consolidate operations where possible and avoid redundant reads.
Do:
func resourceExampleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
// Single API call to get all needed data
resource, _, err := client.Resources.Get(ctx, owner, name)
if err != nil {
if ghErr, ok := errors.AsType[github.ErrorResponse](err); ok {
if ghErr.Response.StatusCode == http.StatusNotFound {
tflog.Info(ctx, "Removing resource from state because it no longer exists", map[string]any{"name": name})
d.SetId("")
return nil
}
}
return diag.FromErr(err)
}
// Set all attributes from single response
if err := d.Set("field1", resource.Field1); err != nil {
return diag.FromErr(err)
}
if err := d.Set("field2", resource.Field2); err != nil {
return diag.FromErr(err)
}
return nil
}Don't:
func resourceExampleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
// Don't make separate calls for each field
field1, err := client.Resources.GetField1(ctx, owner, name)
// ...
field2, err := client.Resources.GetField2(ctx, owner, name)
// ...
}We do not support using local git CLI to operate on repositories. All operations must go through the GitHub API.
Do:
// Use go-github client for all git operations
_, _, err := client.Git.CreateRef(ctx, owner, repo, ref)Don't:
// Never shell out to git CLI
exec.Command("git", "push", "origin", "main")Resources follow a consistent file naming and organization pattern:
github/
├── resource_github_<entity>.go # Main resource implementation
├── resource_github_<entity>_test.go # Acceptance tests
├── resource_github_<entity>_migration.go # State migration functions (if needed)
├── data_source_github_<entity>.go # Data source implementation
├── data_source_github_<entity>_test.go # Data source tests
└── util_<domain>.go # Domain-specific utilities
Use context-aware CRUD functions with the *Context suffix:
func resourceGithubExample() *schema.Resource {
return &schema.Resource{
CreateContext: resourceGithubExampleCreate,
ReadContext: resourceGithubExampleRead,
UpdateContext: resourceGithubExampleUpdate,
DeleteContext: resourceGithubExampleDelete,
Importer: &schema.ResourceImporter{
StateContext: resourceGithubExampleImport,
},
// Include SchemaVersion and StateUpgraders if state migrations exist
SchemaVersion: 1,
StateUpgraders: []schema.StateUpgrader{
{
Type: resourceGithubExampleResourceV0().CoreConfigSchema().ImpliedType(),
Upgrade: resourceGithubExampleInstanceStateUpgradeV0,
Version: 0,
},
},
Schema: map[string]*schema.Schema{
// Schema definition
},
}
}Full reference: Schema Behaviors
Primitive options:
Type: field type (TypeString,TypeBool,TypeInt,TypeFloat,TypeList,TypeSet,TypeMap)Description: human-readable description (always include)Elem: element type forTypeList,TypeSet, andTypeMapfields (e.g.,&schema.Schema{Type: schema.TypeString}or a nested&schema.Resource{})Default: static default value when field is not set in configDefaultFunc: dynamic default (e.g., read from env var viaschema.EnvDefaultFunc)
Behavior flags:
Required: must be provided in config (mutually exclusive withOptional,Computed)Optional: may be omitted from configComputed: set by the provider (API-derived); combine withOptionalfor optional fields with server defaultsForceNew: changing this field destroys and recreates the resourceSensitive: value is masked in plan/state output (secrets, tokens)
Validation:
ValidateDiagFunc: validate field value with diagnostics (preferred)
Constraints:
MaxItems/MinItems: cardinality bounds forTypeListandTypeSetConflictsWith: list of field paths that cannot be set together with this fieldExactlyOneOf: exactly one of these fields must be setAtLeastOneOf: at least one of these fields must be setRequiredWith: these fields must all be set if this field is set
Advanced:
StateFunc: transform value before storing in state (e.g., normalize to lowercase)DiffSuppressFunc: suppress plan diffs when old and new values are semantically equalDiffSuppressOnRefresh: also applyDiffSuppressFuncduring refreshSet: custom hash function forTypeSetelementsDeprecated: marks field as deprecated with a message shown to users
For single-part IDs (most common):
d.SetId(resource.GetName())For composite IDs, use buildID to create and parseID2/parseID3/parseID4 to parse:
// Two-part ID
id, err := buildID(owner, name)
d.SetId(id)
// Parse:
owner, name, err := parseID2(id)
// Three-part ID
id, err := buildID(owner, repo, name)
d.SetId(id)
// Parse:
owner, repo, name, err := parseID3(id)
// Four-part ID
owner, repo, env, name, err := parseID4(id)
// IDs with special characters (colons, etc.)
id, err := buildID(escapeIDPart(part1), part2)Note: The legacy functions
buildTwoPartIDandbuildThreePartID, are deprecated. UsebuildIDinstead.
The provider resolves credentials using the following fallback chain (first match wins):
- Token:
tokenattribute orGITHUB_TOKENenv var - GitHub App:
app_authblock withid,installation_id, andpem_file - GitHub CLI: Falls back to
gh auth tokenif neither token nor app_auth is set. This method will be deprecated in a future release, so users should not rely on it for long-term authentication. - Anonymous: Read-only access when no credentials are available
All authentication configuration is handled in config.go. See the Explicit Authentication Configuration decision for design rationale.
func resourceGithubExampleCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
meta := m.(*Owner)
client := meta.v3client
owner := meta.name
// Implementation
return nil // Never call Read at end of Create, set any Computed fields in Create
}
func resourceGithubExampleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
// Implementation
return nil
}
func resourceGithubExampleUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
// Implementation
return nil // Never call Read at end of Update, set any Computed fields in Update
}
func resourceGithubExampleDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
// Implementation
return nil // Never call Read after Delete
}func resourceExampleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
meta := m.(*Owner)
// REST API client (go-github)
client := meta.v3client
// GraphQL client (for queries not available in REST)
v4client := meta.v4client
// Owner context
owner := meta.name
isOrg := meta.IsOrganization
orgID := meta.id
// ...
}Handle 404s gracefully by removing from state:
resource, _, err := client.Resources.Get(ctx, owner, name)
if err != nil {
if ghErr, ok := errors.AsType[github.ErrorResponse](err); ok {
if ghErr.Response.StatusCode == http.StatusNotFound {
tflog.Info(ctx, "Removing resource from state because it no longer exists", map[string]any{"name": name})
d.SetId("")
return nil
}
}
return diag.FromErr(err)
}Import is registered via the Importer field with a StateContext function. After import runs, Terraform automatically calls Read and so the import function's only job is to set enough state for Read to succeed. Do not duplicate Read logic in the import function.
The import function must parse the user-provided ID and populate any schema attributes that Read depends on:
func resourceGithubExampleImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) {
owner, name, err := parseID2(d.Id())
if err != nil {
return nil, err
}
// Set attributes that Read needs to make API calls
if err := d.Set("owner", owner); err != nil {
return nil, err
}
// Re-build a normalized ID if needed
id, err := buildID(owner, name)
if err != nil {
return nil, err
}
d.SetId(id)
return []*schema.ResourceData{d}, nil
}Key principle: Import sets the minimum state required for Read to fetch the full resource. Read then populates all remaining attributes.
When adding new fields or changing schema, use StateUpgraders (not the deprecated MigrateState):
Migration file (resource_github_example_migration.go):
// resourceGithubExampleResourceV0 returns the schema for version 0
func resourceGithubExampleResourceV0() *schema.Resource {
return &schema.Resource{
Schema: map[string]*schema.Schema{
// Previous schema version
},
}
}
// resourceGithubExampleInstanceStateUpgradeV0 migrates from version 0 to 1
func resourceGithubExampleInstanceStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) {
tflog.Debug(ctx, "State before migration", rawState)
// Add new field with default value
if _, ok := rawState["new_field"]; !ok {
rawState["new_field"] = "default_value"
}
tflog.Debug(ctx, "State after migration", rawState)
return rawState, nil
}Register in resource:
SchemaVersion: 1,
StateUpgraders: []schema.StateUpgrader{
{
Type: resourceGithubExampleResourceV0().CoreConfigSchema().ImpliedType(),
Upgrade: resourceGithubExampleInstanceStateUpgradeV0,
Version: 0,
},
},Use tflog for structured logging (replacing log package):
import "github.com/hashicorp/terraform-plugin-log/tflog"
func resourceExampleCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
tflog.Debug(ctx, "Creating resource", map[string]any{ "name": name, "owner": owner, })
// ...
}Note: Migration from log to tflog is in progress. New code should use tflog.
Our convention for organizing acceptance tests is to define a single TestAcc... function per resource or data source, and then group scenario-specific cases under t.Run(...) subtests within that function. This keeps shared setup in one place while still making each test scenario explicit and easy to maintain.
Use ConfigStateChecks for post-apply state assertions and ConfigPlanChecks for plan-level assertions (e.g., verifying ForceNew triggers). These replace the legacy Check: + resource.ComposeTestCheckFunc pattern.
import (
"github.com/hashicorp/terraform-plugin-testing/compare"
"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/tfjsonpath"
)
func TestAccGithubExample(t *testing.T) {
t.Run("creates resource without error", func(t *testing.T) {
randomID := acctest.RandStrin(5)
testResourceName := fmt.Sprintf("%s%s", testResourcePrefix, randomID)
config := fmt.Sprintf(`
resource "github_example" "test" {
name = "%s"
}
`, testResourceName)
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnauthenticated(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
ConfigStateChecks: []statecheck.StateCheck{
// Verify computed values are populated
statecheck.ExpectKnownValue("github_example.test", tfjsonpath.New("etag"), knownvalue.NotNull()),
statecheck.ExpectKnownValue("github_example.test", tfjsonpath.New("node_id"), knownvalue.NotNull()),
// Compare computed values across resources
statecheck.CompareValuePairs("github_example.test", tfjsonpath.New("repo_id"), "github_repository.test", tfjsonpath.New("repo_id"), compare.ValuesSame()),
},
},
},
})
})
t.Run("forces new when field changes", func(t *testing.T) {
// ... config steps ...
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnauthenticated(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{Config: configBefore},
{
Config: configAfter,
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("github_example.test", plancheck.ResourceActionDestroyBeforeCreate),
},
},
},
},
})
})
}Legacy pattern: Existing tests may still use
Check: resource.ComposeTestCheckFunc(...). New tests should useConfigStateChecksandConfigPlanChecksinstead. Seedata_source_github_ip_ranges_test.gofor a real-world example.
Use these skip functions to run tests in appropriate contexts:
skipUnauthenticated(t): skips if anonymous modeskipUnlessHasOrgs(t): requires organization, team, or enterprise modeskipUnlessHasPaidOrgs(t): requires team or enterprise mode (paid orgs)skipUnlessEnterprise(t): requires enterprise modeskipUnlessHasAppInstallations(t): requires GitHub App installationsskipUnlessEMUEnterprise(t): requires EMU enterpriseskipIfEMUEnterprise(t): skips if EMU enterpriseskipUnlessMode(t, testModes...): generic mode check
| Mode | Environment Variables Required |
|---|---|
anonymous |
None (read-only, GitHub.com only) |
individual |
GITHUB_TOKEN + GITHUB_OWNER |
organization |
GITHUB_TOKEN + GITHUB_ORGANIZATION |
enterprise |
Enterprise deployment configuration |
# Run specific acceptance test
make testacc T=TestAccGithubExample
# Run unit tests (non-acceptance)
make test
# With coverage
make testacc T=TestAccGithubExample COV=true
# Clean up leaked test resources (repos, teams prefixed with tf-acc-test-)
make sweep# With debug logging
TF_LOG=DEBUG make testacc T=TestAccGithubExampleThis section documents provider-specific quirks and known limitations discovered through development.
The following resources are deprecated and will be removed in future versions:
| Deprecated Resource | Replacement |
|---|---|
github_organization_security_manager |
github_organization_role_team |
github_organization_custom_role |
github_organization_repository_role |
github_organization_role_team_assignment |
github_organization_role_team |
github_repository_deployment_branch_policy |
github_repository_environment_deployment_policy |
github_organization_project |
None (Classic Projects API removed) |
github_project_card |
None (Classic Projects API removed) |
github_project_column |
None (Classic Projects API removed) |
github_repository_project |
None (Classic Projects API removed) |
| Deprecated Data Source | Replacement |
|---|---|
github_organization_custom_role |
github_organization_repository_role |
github_organization_security_managers |
github_organization_role_teams |
github_repository_deployment_branch_policies |
github_repository_environment_deployment_policies |
- Branch Protection
contexts: Deprecated, use thechecksarray instead - Runner Groups: Selected repository IDs are not exposed via API (
resource_github_actions_runner_group.go:179) - Organization Settings: Test requires manual cleanup (
resource_github_organization_settings_test.go:11) - Repository Search: Tests may hit rate limits (
data_source_github_repositories_test.go:12)
- EMU with SSO: Odd behavior with user tokens when using Enterprise Managed Users (
resource_github_enterprise_organization.go:122)
ID Parsing & Building (util.go):
| Function | Purpose |
|---|---|
buildID(parts...) |
Create composite ID (e.g., "a:b", "a:b:c") |
parseID2(id) |
Parse two-part composite ID |
parseID3(id) |
Parse three-part composite ID |
parseID4(id) |
Parse four-part composite ID |
escapeIDPart(part) |
Escape colons in ID parts |
buildChecksumID(v) |
Create MD5-based checksum ID from string slice |
Error Handling & Validation (util.go):
| Function | Purpose |
|---|---|
checkOrganization(meta) |
Verify org context |
validateValueFunc(values) |
Create enum validator from allowed values |
validateSecretNameFunc |
Validate GitHub secret naming rules |
caseInsensitive() |
DiffSuppressFunc for case-insensitive fields |
Data Conversion (util.go):
| Function | Purpose |
|---|---|
expandStringList([]any) |
Convert to []string |
flattenStringList([]string) |
Convert to []any |
Team & Repository Resolution (util_team.go, util_v4_repository.go):
| Function | Purpose |
|---|---|
getTeamID(ctx, meta, idOrSlug) |
Resolve team ID from ID or slug |
getTeamSlug(ctx, meta, idOrSlug) |
Resolve team slug from ID or slug |
getRepositoryID(name, meta) |
Resolve repository node ID from name |
Permission Mapping (util_permissions.go):
| Function | Purpose |
|---|---|
getPermission(permission) |
Normalize permission names (read↔pull, write↔push) |
CustomizeDiffFuncs (util_diff.go):
| Function | Purpose |
|---|---|
diffRepository |
Force replacement on repo ID change |
diffSecret |
Detect remote secret drift via timestamps |
diffSecretVariableVisibility |
Validate selected_repository_ids vs visibility |
diffTeam |
Force new resource on team ID change |
| Component | Pattern | Example |
|---|---|---|
| Resource function | resourceGithub<Entity> |
resourceGithubRepository |
| Data source function | dataSourceGithub<Entity> |
dataSourceGithubRepository |
| CRUD functions | resourceGithub<Entity><Op> |
resourceGithubRepositoryCreate |
| Migration function | resourceGithub<Entity>InstanceStateUpgradeV<N> |
resourceGithubRepositoryInstanceStateUpgradeV0 |
| Schema function | resourceGithub<Entity>ResourceV<N> |
resourceGithubRepositoryResourceV0 |
| Test function | TestAccGithub<Entity> |
TestAccGithubRepository |
| Utility file | util_<domain>.go |
util_rules.go |