You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 (ctxEtag → If-None-Match, via etagTransport in github/transport.go) and short-circuit on 304 Not Modifiedbefore 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)
iferr!=nil {
varghErr*github.ErrorResponseiferrors.As(err, &ghErr) {
ifghErr.Response.StatusCode==http.StatusNotModified {
returnnil// 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):
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):
Start from a resource where config == state == live (here: a ruleset with 4 bypass actors).
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 apply → HTTP 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).
Fix the external cause (here: authorize the app on the repo). Live resource still unchanged.
Run terraform plan.
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:
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.
Expected Behavior
On every refresh, a resource's
Readreconciles the Terraform state attributes with the live GitHub resource, so any divergence between state and reality surfaces as drift on the nextterraform plan.Actual Behavior
Resources using the legacy etag pattern send a conditional
GETwith the etag stored in Terraform state (ctxEtag→If-None-Match, viaetagTransportingithub/transport.go) and short-circuit on304 Not Modifiedbefore anyd.Setcall:The logic flaw: a
304only 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 anUpdatefails (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, andterraform planreportsNo changes.while state and reality disagree. Eventerraform plan/apply -refresh-onlycannot 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) orterraform state rm+import.This was root-caused with full forensic evidence (provider
TF_LOG=DEBUGtraces, 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; theRead/etag code path is byte-for-byte identical inv6.12.1(latest release) and currentmain. The new non-legacy client onmain(unreleased) ignores resource-level etags and should structurally avoid this — butlegacy_clientdefaults totrue, so default configurations remain on the affected path even after it ships.GitHub Installation Type
Affected Resource(s)
Every resource using the
ctxEtagconditional-read idiom (grep ofctxEtagonmain):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_blockThe 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_rulesetwith 4Integrationbypass_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):
terraform apply→HTTP 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).terraform plan.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:
Apply — PUT of 5 actors rejected:
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.:Meanwhile an unconditional
gh api /repos/my-org/my-repo/rulesets/123456 --jq '.bypass_actors'returns the 4 real actors —55555has 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
200and drift is detected correctly. The failure occurs precisely and only on304.Additional context
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.304short-circuit and always perform a full read; or handle conditional requests at the HTTP layer with a cache that replays the stored body on304(as the new non-legacy client does); or retire the resource-level etag pattern altogether once the new client ships — possibly flipping thelegacy_clientdefault.github_repository_ruleset-> bypass_actor drift not reflected in terraform plan #3509 (bypass_actorsdrift not detected), which is one symptom of this pattern.Code of Conduct