Skip to content

Latest commit

 

History

History
661 lines (511 loc) · 24.6 KB

File metadata and controls

661 lines (511 loc) · 24.6 KB

Architecture Guide

This document serves as a guide for contributors implementing new features and resources in the Terraform Provider for GitHub.


Module Map

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

Core Principles

1. One Resource = One API Entity

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 { ... }

2. Minimize API Calls

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)
    // ...
}

3. API-Only Operations

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")

Resource Design

File Organization

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

Resource Structure

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
        },
    }
}

Schema Field Guidelines

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 for TypeList, TypeSet, and TypeMap fields (e.g., &schema.Schema{Type: schema.TypeString} or a nested &schema.Resource{})
  • Default: static default value when field is not set in config
  • DefaultFunc: dynamic default (e.g., read from env var via schema.EnvDefaultFunc)

Behavior flags:

  • Required: must be provided in config (mutually exclusive with Optional, Computed)
  • Optional: may be omitted from config
  • Computed: set by the provider (API-derived); combine with Optional for optional fields with server defaults
  • ForceNew: changing this field destroys and recreates the resource
  • Sensitive: value is masked in plan/state output (secrets, tokens)

Validation:

  • ValidateDiagFunc: validate field value with diagnostics (preferred)

Constraints:

  • MaxItems / MinItems: cardinality bounds for TypeList and TypeSet
  • ConflictsWith: list of field paths that cannot be set together with this field
  • ExactlyOneOf: exactly one of these fields must be set
  • AtLeastOneOf: at least one of these fields must be set
  • RequiredWith: 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 equal
  • DiffSuppressOnRefresh: also apply DiffSuppressFunc during refresh
  • Set: custom hash function for TypeSet elements
  • Deprecated: marks field as deprecated with a message shown to users

ID Patterns

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 buildTwoPartID and buildThreePartID, are deprecated. Use buildID instead.


Implementation Patterns

Authentication

The provider resolves credentials using the following fallback chain (first match wins):

  1. Token: token attribute or GITHUB_TOKEN env var
  2. GitHub App: app_auth block with id, installation_id, and pem_file
  3. GitHub CLI: Falls back to gh auth token if 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.
  4. 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.

CRUD Function Signatures

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
}

Accessing the API Client

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

    // ...
}

Error Handling

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

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.

State Migrations

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,
    },
},

Logging

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.


Testing

Test Structure

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 use ConfigStateChecks and ConfigPlanChecks instead. See data_source_github_ip_ranges_test.go for a real-world example.

Test Modes

Use these skip functions to run tests in appropriate contexts:

  • skipUnauthenticated(t): skips if anonymous mode
  • skipUnlessHasOrgs(t): requires organization, team, or enterprise mode
  • skipUnlessHasPaidOrgs(t): requires team or enterprise mode (paid orgs)
  • skipUnlessEnterprise(t): requires enterprise mode
  • skipUnlessHasAppInstallations(t): requires GitHub App installations
  • skipUnlessEMUEnterprise(t): requires EMU enterprise
  • skipIfEMUEnterprise(t): skips if EMU enterprise
  • skipUnlessMode(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

Running Tests

# 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

Debugging Tests

# With debug logging
TF_LOG=DEBUG make testacc T=TestAccGithubExample

Gotchas & Known Issues

This section documents provider-specific quirks and known limitations discovered through development.

Deprecated Resources

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 Sources

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

Known Limitations

  • Branch Protection contexts: Deprecated, use the checks array 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)

Workarounds in Code

  • EMU with SSO: Odd behavior with user tokens when using Enterprise Managed Users (resource_github_enterprise_organization.go:122)

Appendix

Common Utilities

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 (readpull, writepush)

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

Naming Conventions

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