A CLI tool that automatically detects and corrects Terraform/OpenTofu configuration
drift. It runs a plan, reads the before/after diff from the plan JSON, edits the
relevant .tf files in-place using the hclwrite AST library, and validates the
result with a second plan.
Written in Go. No external CLI tools required — HCL is edited directly via the hashicorp/hcl library.
- Use as a GitHub Action
- How It Works
- Building
- Usage
- Environment Variables
- Comment Hook
- Supported Features
- Known Limitations
- Testing
- Project Structure
This repository ships a composite action that builds the drift-fixer binary, runs it against your Terraform/OpenTofu config, and either opens a pull request, commits directly, or just reports drift.
name: Fix Terraform Drift
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
fix-drift:
runs-on: ubuntu-latest
permissions:
contents: write # push branches / commits
pull-requests: write # open PRs
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
- uses: mongodb-forks/drift-fixer@master
with:
path: ./infra
mode: prComplete example workflows:
examples/example-usage.yml—prmode with the defaultGITHUB_TOKEN.examples/example-usage-app-commit.yml—commitmode pushing as a GitHub App (for branch-ruleset bypass).
- Sets up Go and builds the drift-fixer binary from the action's source.
- Runs
tofu init(orterraform init) inpath, retrying up to 3 attempts with exponential backoff to absorb transient registry/CDN 502s. - Runs the binary, which produces drift fixes in
.tffiles. - Reverts any platform-specific h1 hashes
initappended to.terraform.lock.hclso they don't ride along in the PR. - Depending on
mode, opens a PR, commits in place, or just reports.
The step fails the workflow on any non-zero exit from the binary — including the case where drift remained after the fix attempt — so partial-state PRs never ship.
| Name | Default | Description |
|---|---|---|
path |
. |
Directory containing your .tf files. |
tf-bin |
tofu |
Binary used for plan/init. Set to terraform for Terraform. |
mode |
pr |
pr opens a pull request, commit pushes directly to the current branch, dry-run reports without writing. |
verbose |
false |
Print every attribute and block change as it is applied. |
pr-base |
repository default branch | Base branch the PR targets. Required when the workflow runs on a detached HEAD (e.g. scheduled runs); the default is usually correct. |
pr-branch |
drift-fixer/fix-<run_id> |
Branch name used in mode: pr. |
pr-title |
fix: sync Terraform config with infrastructure drift |
Pull request title. |
pr-draft |
true |
Open the PR as a draft. Set to "false" for ready-for-review. |
commit-message |
fix: sync Terraform config with infrastructure drift |
Commit message used in both pr and commit modes. |
git-user-name |
github-actions[bot] |
Commit author/committer name in commit mode. Override when pushing as a GitHub App. |
git-user-email |
github-actions[bot]@users.noreply.github.com |
Commit author/committer email in commit mode. |
token |
${{ github.token }} |
Token used for gh CLI calls (e.g. PR creation). For push auth, see "Pushing as a GitHub App" below. |
| Name | Description |
|---|---|
drift-detected |
'true' if drift was found (and fixed), 'false' otherwise. |
pr-url |
URL of the opened PR when mode: pr. Empty otherwise. |
pr— fix drift on a new branch, open a (by default) draft PR. The PR is only opened when the post-fix validation plan reports no remaining drift.commit— fix drift and push directly to the current branch using the configured commit message. No PR is opened.dry-run— run plan and report what would change without writing anything. The action never opens a PR or commits in this mode.
When you need the commit to come from a GitHub App — typically so the App can
be added to a branch ruleset bypass list — mint an installation token in the
workflow, hand it to actions/checkout (so git push is authenticated as the
App), and override the commit identity:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.DRIFT_FIXER_APP_ID }}
private-key: ${{ secrets.DRIFT_FIXER_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }} # so git push is authenticated as the App
- uses: opentofu/setup-opentofu@v1
- uses: mongodb-forks/drift-fixer@master
with:
path: ./infra
mode: commit
token: ${{ steps.app-token.outputs.token }}
git-user-name: "${{ steps.app-token.outputs.app-slug }}[bot]"
git-user-email: "${{ steps.app-token.outputs.installation-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"The push is authenticated by whatever credentials actions/checkout
persisted, so passing the App token there is what makes the bypass match
work. The git-user-name / git-user-email inputs control how the commit
is displayed — set them to the App's identity so commit listings show the
App as author.
-
Plan — runs
tofu plan -out <tmp>thentofu show -json <tmp>in the target directory. Reads two arrays from the JSON output:resource_changes[*].change.{before,after}— for eachupdate/replace, diffs every attribute and collects keys wherebefore != afterinto aResourceDrift. The post-refreshbeforevalue (real infra) is what gets written back to config.resource_drift[*]— surfaces resources that disappeared from real infra (e.g. deleted via a provider's web console). These appear inresource_changesascreate, which the loop above skips, so the planner cross-references and emits aDelete: truedrift only whenresource_changesplans to recreate the resource (i.e. config still has the block).
-
Find — parses every
.tffile in the project directory using thehclsyntaxAST to locate which file contains each drifted resource block. -
Edit — opens the file with
hclwrite, walks the block tree, and applies each drifted attribute:- Scalar attributes are set with
SetAttributeValue/SetAttributeRaw. - List attributes with more than one item are formatted one-item-per-line.
- Block-typed values (nested blocks) are recursively synced.
- Resources deleted from infra are removed from config entirely, along
with any
import { to = <addr> }block targeting them. - The output is run through
hclwrite.Format(equivalent totofu fmt).
- Scalar attributes are set with
-
Validate — runs a second plan. If no drift remains, exits 0. If drift persists, exits 1 with a summary.
Requirements
- Go 1.22+ (
go version) - OpenTofu or Terraform CLI on
$PATH(default:tofu) GOTOOLCHAIN=localis required becausego.modpins 1.22 while some transitive dependencies were built with a newer toolchain declaration.
# Clone
git clone <repo-url>
cd drift-fixer
# Build binary to project root
cd go
GOTOOLCHAIN=local go build -o ../drift-fixer-go ./cmd/drift-fixer/
# Verify
cd ..
./drift-fixer-go -hThe resulting binary drift-fixer-go is self-contained with no runtime
dependencies.
./drift-fixer-go [flags]
Flags:
-path string
Path to the Terraform/OpenTofu project directory (default ".")
-tf-bin string
Terraform/OpenTofu binary to use (default "tofu", or $DRIFT_FIXER_TF_BIN)
-verbose
Print every attribute and block change as it is applied
-dry-run
Detect and report drift without writing any files
# Fix drift in the current directory using tofu
./drift-fixer-go
# Fix a specific project directory
./drift-fixer-go -path ./infra/github
# Use terraform instead of tofu
./drift-fixer-go -tf-bin terraform
# Preview what would change without editing files
./drift-fixer-go -dry-run -verbose
# Verbose mode shows every attribute as it is applied
./drift-fixer-go -verboseStarting drift analysis in: /home/user/infra/github
Using CLI binary: tofu
🔍 Running plan to detect drift...
📊 Found 2 resource(s) with drift:
- github_repository_ruleset.main
- github_repository.app
📝 Applying fixes...
Syncing github_repository_ruleset.main in rulesets.tf
✅ Fixed github_repository_ruleset.main in rulesets.tf
Syncing github_repository.app in repos.tf
✅ Fixed github_repository.app in repos.tf
✅ Validating...
✅ Drift fixing completed successfully! No remaining drift.
| Variable | Default | Description |
|---|---|---|
DRIFT_FIXER_TF_BIN |
tofu |
Terraform/OpenTofu binary to invoke |
DRIFT_FIXER_COMMENT_SCRIPT |
(unset) | Path to an executable script that generates inline HCL comments (see Comment Hook) |
When DRIFT_FIXER_COMMENT_SCRIPT is set, drift-fixer can annotate written
values with inline # comments. The script is invoked once per value that
needs a comment.
drift-fixer uses a two-pass approach:
- Pass 1 — write the fix. The resource is written to disk with all drift corrections applied and all existing comments preserved. No script is called yet.
- Pass 2 — annotate. For each value in the drifted resource that has no existing comment, the script is called. Only the resources that had drift are processed — the rest of the file is untouched. Because the file has already been written, the script can read it to use surrounding context when deciding what comment to generate.
Existing inline comments are always preserved and take priority over script-generated ones — the script is never called for a value that already has a comment.
Built-in comment logic (e.g. annotating repository_ids entries with their
data.github_repository.* reference) runs in pass 1 and also suppresses the
script for those values.
| Variable | Example | Description |
|---|---|---|
DRIFT_RESOURCE_TYPE |
github_repository_ruleset |
Terraform resource type |
DRIFT_RESOURCE_NAME |
ruleset_main |
Terraform resource name |
DRIFT_ATTR_PATH |
conditions.ref_name.include |
Dot-separated attribute path |
DRIFT_ATTR_VALUE |
"~DEFAULT_BRANCH" |
Rendered value (strings are quoted) |
DRIFT_FILE_PATH |
/path/to/main.tf |
Absolute path of the file already written to disk |
All environment variables from the parent process (including GITHUB_TOKEN,
GITHUB_WORKSPACE, etc.) are also inherited.
- Print a single line to stdout — the comment text, without
#. - Print nothing (or exit non-zero) to add no comment.
- Stderr is captured and shown on error (or in verbose mode).
#!/usr/bin/env bash
# Annotate GitHub branch patterns with a human-readable description.
# $DRIFT_FILE_PATH is available if you need to read the full config for context.
case "$DRIFT_ATTR_VALUE" in
'"~DEFAULT_BRANCH"') echo "the default branch" ;;
'"~ALL"') echo "all branches" ;;
'"refs/heads/releases/**/*"') echo "release branches" ;;
esacWith this script active, a synced include list might look like:
include = [
"~DEFAULT_BRANCH", # the default branch
"refs/heads/releases/**/*", # release branches
"refs/heads/meep",
]| Type | Supported | Notes |
|---|---|---|
| String | ✅ | Any content including slashes, regex, special chars |
| Boolean | ✅ | |
| Integer | ✅ | Large integers (e.g. GitHub App IDs) rendered as integers, never scientific notation |
| Float | ✅ | |
| String list | ✅ | Multi-line for >1 item, single-line for 1 item |
| Number list | ✅ | Same formatting rules |
| Nested block | ✅ | Recursively synced to arbitrary depth |
| Repeated blocks | ✅ | e.g. multiple bypass_actors {} |
| Null value | ✅ | Silently skipped |
Real infra is the source of truth: drift-fixer makes config match it exactly, regardless of which side has more or fewer blocks.
| Operation | Behaviour |
|---|---|
| Add missing block | If a block type is entirely absent from config, all instances from infra are added |
| Sync existing block | Attributes inside existing blocks are updated in-place |
| Append missing entries | If config has fewer instances of a repeated block than infra, the missing entries are appended |
| Remove excess blocks | If config has more instances of a repeated block than infra, the extras are removed |
| Remove empty block type | If infra returns [] and config has blocks of that type, all are removed |
| Skip empty scalar list | If infra returns [] and no blocks of that type exist, it is ignored (not written as = []) |
| Delete resource | If a resource is deleted from infra (actions: ["delete"]), the entire resource {} block is removed from config |
# Single item — stays on one line
topics = ["terraform"]
# Multiple items — one per line, `tofu fmt`-compliant indentation
topics = [
"terraform",
"go",
"github",
]Comments in list attributes survive drift syncs:
- Before-line comments (
# commenton its own line before a value) - Inline comments (
# commentat the end of the same line as a value) //style comments- Comments are keyed by rendered value content, not position — they follow their value even if the list is reordered or items are inserted before them.
- Comments for removed items are dropped.
include = [
# default branch — always included
"~DEFAULT_BRANCH", # added automatically by provider
"refs/heads/releases/**/*", # release train
]| Resource | Notes |
|---|---|
github_repository |
description, visibility, topics, all boolean flags, security_and_analysis nested blocks, squash_merge_commit_title/message, merge_commit_title/message |
github_repository_ruleset |
enforcement, target, conditions.ref_name.{include,exclude}, bypass_actors, all rules.* sub-blocks listed below |
rules.pull_request |
allowed_merge_methods, required_approving_review_count, all boolean flags |
rules.required_status_checks |
required_check repeated blocks (add, remove, sync) |
rules.required_deployments |
required_deployment_environments list |
rules.branch_name_pattern |
operator, pattern, name, negate |
rules.tag_name_pattern |
same schema as branch_name_pattern |
rules.commit_message_pattern |
same schema |
rules.commit_author_email_pattern |
same schema |
rules.committer_email_pattern |
same schema |
rules.required_code_scanning |
required_code_scanning_tool repeated blocks |
rules.file_path_restriction |
restricted_file_paths list |
rules.file_extension_restriction |
restricted_file_extensions list |
rules.max_file_size |
max_file_size integer |
rules.max_file_path_length |
max_file_path_length integer |
rules.merge_queue |
all integer and string fields |
The tool is provider-agnostic — it works on any Terraform/OpenTofu resource. The GitHub provider has been the primary test target.
- Sensitive attributes — attributes marked sensitive in the plan JSON are skipped (the provider redacts their values, so drift-fixer cannot know the correct value).
after_unknownattributes — attributes whose post-change value is not known at plan time are skipped.- Multiple resources with the same type+name — only the first match in a file is edited. This is a theoretical edge case since Terraform requires unique addresses.
for_each/countmeta-arguments — resources managed withcountorfor_eachare located by address label. The tool edits the block body directly; it does not currently modify thecountorfor_eachexpression itself.- Comment hook is synchronous — the script is exec'd once per value that needs a comment (pass 2 only). Values with existing comments are skipped. For large syncs with many uncommented list items, a slow script will slow the overall run.
All unit tests live under go/internal/ and use only the standard library
(testing package). No real cloud credentials or live API calls are made.
# Run everything (75 tests across 3 files)
cd go
GOTOOLCHAIN=local go test ./...
# Just the editor or planner suites
GOTOOLCHAIN=local go test ./internal/editor/ -v
GOTOOLCHAIN=local go test ./internal/planner/ -v
# Run a specific test by name
GOTOOLCHAIN=local go test ./internal/editor/ -v -run TestBypassActorModeChange| File | Count | Coverage |
|---|---|---|
internal/editor/editor_test.go |
18 | Core edit operations, multi-line lists, comment preservation, hook |
internal/editor/editor_extended_test.go |
48 | GitHub provider shapes, list mutations, deep nesting, comment edge cases, hook path verification |
internal/planner/planner_test.go |
9 | Plan-JSON parsing: attribute drift, polymorphic before_sensitive/after_unknown, sensitive-attr filtering, resource_drift delete handling, post-fix validation idempotency |
Requires a real GitHub token and the examples/ directory pointing at a live
repository:
# Set up credentials
echo 'export GITHUB_TOKEN=ghp_...' > .env
# Run full end-to-end: build → checkout clean main.tf → plan → fix → validate
./test.sh
# Dry run
./test.sh --dry-runThe script:
- Builds the binary from source.
- Resets
examples/main.tfto HEAD withgit checkout. - Runs
drift-fixer-go -path examples/ -verbose. - Exits 0 only if the second plan confirms no remaining drift.
drift-fixer/
├── action.yml # composite GitHub Action definition
├── drift-fixer-go # compiled binary (gitignored)
├── test.sh # integration test script
├── examples/
│ ├── main.tf # test fixture (github provider resources)
│ └── example-usage.yml # workflow consumers can copy into their repo
└── go/
├── go.mod
├── go.sum
├── cmd/
│ └── drift-fixer/
│ └── main.go # CLI entry point: flags, orchestration, validate
└── internal/
├── planner/
│ ├── planner.go # runs plan+show-json, parses plan JSON, returns ResourceDrift list
│ └── planner_test.go # unit tests for parsePlanJSON
├── finder/
│ └── finder.go # parses .tf AST to locate which file contains each resource
└── editor/
├── editor.go # hclwrite-based editor: ApplyDrift, RemoveResource, syncBody
├── hook.go # CommentHook type + built-in comment generators
├── editor_test.go # core unit tests (18)
└── editor_extended_test.go # extended GitHub provider tests (48)
planner
- Runs
<tf-bin> plan -out <tmp>followed by<tf-bin> show -json <tmp>. parsePlanJSON(unit-tested independently of tofu) does the analysis:resource_changesupdate/replace→ walkschange.beforevschange.after, collects every key where the values differ; skips sensitive keys andafter_unknownmarkers.resource_changesdelete→ emitsResourceDrift{Delete: true}.resource_driftdelete→ emitsResourceDrift{Delete: true}only whenresource_changesfor the same address planscreate(i.e. config still has the block). Without this guard, the post-fix validation plan would re-flag the same delete every time refresh runs.
finder
- Walks the project directory for
*.tffiles. - Parses each file with
hclsyntax.ParseConfig(full AST, no regex). - Returns a map of
resourceAddress → filePath.
editor
ApplyDrift— main entry point; opens file, finds resource block, callssyncBody, writes formatted output.RemoveResource— removes the entireresource {}block, plus anyimport { to = <addr> }block in the same file targeting it (otherwise tofu plan errors with "Configuration for import target does not exist").syncBody— recursive; handles empty lists, block vs scalar distinction, block count policy, path accumulation for hooks.setAttributeVal— scalar/list writer; extracts and re-injects comments; calls hook for values with no existing comment.extractItemComments— token-stream parser that maps rendered value string →{before, inline}comment tokens. Keyed by value content so comments survive list reordering.multilineListTokens— buildshclwrite.Tokensfor a multi-line list, re-inserting preserved comments and hook-generated comments in the right spots.
hook
CommentHook— function type(rType, rName, path, value, filePath) → comment.LoadCommentHook— readsDRIFT_FIXER_COMMENT_SCRIPT; returns a hook that exec's the script with context via env vars (includingDRIFT_FILE_PATH), ornilif unset.ComposeHooks— chains two hooks; tries primary first, falls back to secondary.