Skip to content

[BUG]: Legacy etag pattern — Read short-circuits on HTTP 304 and can serve stale state indefinitely (drift never detected) #3519

Description

@Angak0k

Expected Behavior

On every refresh, a resource's Read reconciles the Terraform state attributes with the live GitHub resource, so any divergence between state and reality surfaces as drift on the next terraform plan.

Actual Behavior

Resources using the legacy etag pattern send a conditional GET with the etag stored in Terraform state (ctxEtagIf-None-Match, via etagTransport in github/transport.go) and short-circuit on 304 Not Modified before any d.Set call:

if !d.IsNewResource() {
    ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string))
}
ruleset, resp, err := client.Repositories.GetRuleset(ctx, owner, repoName, rulesetID, false)
if err != nil {
    var ghErr *github.ErrorResponse
    if errors.As(err, &ghErr) {
        if ghErr.Response.StatusCode == http.StatusNotModified {
            return nil // state attributes are never refreshed
        }
        ...

The logic flaw: a 304 only proves the live resource is unchanged since that etag was minted. It does not prove the state attributes are accurate — because the attributes and the etag can be written to state from different sources. Concretely, when an Update fails (e.g. HTTP 422), the planned attribute values are persisted to state (standard legacy-SDK behavior) while the etag keeps the value from the pre-apply refresh, i.e. the etag of the live, unchanged resource. State is then internally inconsistent: a valid etag glued to attribute values that don't match what that etag identifies.

From that point on the divergence is permanent: every refresh sends the still-valid etag, gets 304, skips the re-sync, and terraform plan reports No changes. while state and reality disagree. Even terraform plan/apply -refresh-only cannot heal it — it takes the same code path. The only recovery is an out-of-band modification of the live resource (which invalidates the etag) or terraform state rm + import.

This was root-caused with full forensic evidence (provider TF_LOG=DEBUG traces, the raw state file written by the failing run, independent API reads) in #3509 (comment). Opening this generic issue at the maintainers' request (cc @stevehipwell @deiga) since the defect is in the shared pattern, not in any single resource.

Terraform Version

Terraform 1.15.7. Provider: reproduced on v6.11.1; the Read/etag code path is byte-for-byte identical in v6.12.1 (latest release) and current main. The new non-legacy client on main (unreleased) ignores resource-level etags and should structurally avoid this — but legacy_client defaults to true, so default configurations remain on the affected path even after it ships.

GitHub Installation Type

  • GitHub.com (Free, Pro, or Team)
  • GitHub Enterprise Server (on-premises)
  • GitHub Enterprise Cloud with Personal Accounts (github.com)
  • GitHub Enterprise Cloud with Managed Users/EMU (github.com)
  • GitHub Enterprise Cloud with Data Residency (*.ghe.com)
  • I don't know

Affected Resource(s)

Every resource using the ctxEtag conditional-read idiom (grep of ctxEtag on main):

github_actions_runner_group, github_branch, github_branch_default, github_branch_protection_v3, github_emu_group_mapping, github_enterprise_actions_runner_group, github_issue, github_issue_label, github_membership, github_organization_project, github_organization_ruleset, github_organization_webhook, github_project_card, github_project_column, github_repository, github_repository_autolink_reference, github_repository_deploy_key, github_repository_deployment_branch_policy, github_repository_project, github_repository_ruleset, github_repository_webhook, github_team, github_team_membership, github_team_repository, github_team_sync_group_mapping, github_user_gpg_key, github_user_ssh_key, github_organization_block

The demonstrated case is github_repository_ruleset (bypass_actors), but nothing in the mechanism is specific to it.

Terraform Configuration Files

See #3509 — a github_repository_ruleset with 4 Integration bypass_actors plus a 5th referencing a GitHub App not installed on the repository.

Steps to Reproduce

Deterministic reproducer (verified end-to-end in #3509; any sequence that leaves state attributes ≠ live while the stored etag still validates will do):

  1. Start from a resource where config == state == live (here: a ruleset with 4 bypass actors).
  2. Change the config in a way GitHub will reject at apply time (here: add a bypass actor for a GitHub App not installed on the repo) and run terraform applyHTTP 422. The planned values are persisted to state; the etag in state stays the one from the pre-apply refresh (still valid, since live never changed).
  3. Fix the external cause (here: authorize the app on the repo). Live resource still unchanged.
  4. Run terraform plan.
  5. Expected: an update (config differs from live). Actual: No changes. — on this and every subsequent plan.

Debug Output

Condensed from the full evidence in #3509 (comment) (same sanitization; note the same etag appearing at every step):

Pre-apply refresh of the failing run — live has 4 actors, etag captured:

2026-07-02T18:40:35Z [DEBUG] ... GET /repos/my-org/my-repo/rulesets/123456  → 200
  Etag="W/\"aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa1111bbbb2222\""
  body: "bypass_actors":[{11111},{22222},{33333},{44444}]

Apply — PUT of 5 actors rejected:

Error: PUT .../rulesets/123456: 422 Validation Failed
  [{Message: Actor <my-app-5> integration must be part of the ruleset source or owner organization}]

State file written by that errored run — planned (rejected) attributes + the refresh's etag:

{
  "etag": "W/\"aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa1111bbbb2222\"",
  "bypass_actors": [ {22222}, {33333}, {44444}, {55555}, {11111} ]
}

Every subsequent plan — conditional read 304s, state never re-synced, No changes.:

2026-07-03T05:59Z [DEBUG] ... GET /repos/my-org/my-repo/rulesets/123456  → 304 Not Modified
  tf_http_res_body=""
Plan: 0 to add, 0 to change, 0 to destroy.

Meanwhile an unconditional gh api /repos/my-org/my-repo/rulesets/123456 --jq '.bypass_actors' returns the 4 real actors — 55555 has never existed on the live resource, yet plan is silent about it.

Control: whenever the live resource is modified out-of-band (etag invalidated), the next refresh gets a 200 and drift is detected correctly. The failure occurs precisely and only on 304.

Additional context

  • Etag validation appears to be auth-token-bound (Vary: ... Authorization ... on the API responses), so the freeze primarily affects static-token (PAT) setups; GitHub App tokens rotate and break the etag match as a side effect. This also makes the bug intermittent and hard to attribute in the wild.
  • Possible directions for a fix, for discussion: drop the 304 short-circuit and always perform a full read; or handle conditional requests at the HTTP layer with a cache that replays the stored body on 304 (as the new non-legacy client does); or retire the resource-level etag pattern altogether once the new client ships — possibly flipping the legacy_client default.
  • Supersedes the root-cause tracking of [BUG]: github_repository_ruleset -> bypass_actor drift not reflected in terraform plan #3509 (bypass_actors drift not detected), which is one symptom of this pattern.

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: BugSomething isn't working as documented

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions