Auto-Merging Terraform PRs with No-Op Plans
Six months ago, our Terraform PR queue was backing up. Engineers were waiting days for reviews on changes that required zero infrastructure modification — documentation updates, adding a tag to a label map, updating a comment in a module. Reviewers were rubber-stamping these PRs because there was nothing to review. Meanwhile, PRs that actually changed infrastructure were mixed into the same queue and getting less attention than they deserved.
The fix was automatic merging for no-op plans. Here's how it works and how we built it.
The Core Mechanism: terraform plan -detailed-exitcode
Terraform has an exit code convention that makes this possible:
- Exit code
0: success, no changes - Exit code
1: error - Exit code
2: success, changes present
The -detailed-exitcode flag activates this behavior. Without it, terraform plan exits 0 whether or not there are changes.
terraform plan -detailed-exitcode -out=plan.tfplan
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "No changes — plan is a no-op"
elif [ $EXIT_CODE -eq 2 ]; then
echo "Changes detected — human review required"
elif [ $EXIT_CODE -eq 1 ]; then
echo "Plan failed — investigation required"
exit 1
fi
This is the entire decision tree. If the plan exits 0, we can auto-merge. If it exits 2, we require human review. If it exits 1, the plan is broken and the PR is blocked until fixed.
GitHub Actions Workflow
The workflow runs on every PR targeting our Terraform repositories. It's split into two jobs: the plan job that runs Terraform and captures the exit code, and the auto-merge job that conditionally merges if the plan was a no-op.
name: Terraform Plan and Auto-Merge
on:
pull_request:
branches: [main]
jobs:
terraform-plan:
runs-on: ubuntu-latest
outputs:
plan-exit-code: ${{ steps.plan.outputs.exit-code }}
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: "1.5.0"
- name: Authenticate to GCP
uses: google-github-actions/auth@v1
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.TF_SERVICE_ACCOUNT }}
- name: Terraform Init
run: terraform init -backend-config=environments/prod/backend.tfvars
- name: Terraform Plan
id: plan
run: |
set +e
terraform plan -detailed-exitcode -out=plan.tfplan
EXIT_CODE=$?
echo "exit-code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
if [ $EXIT_CODE -eq 1 ]; then
echo "Terraform plan failed"
exit 1
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v3
with:
name: terraform-plan
path: plan.tfplan
auto-merge:
runs-on: ubuntu-latest
needs: terraform-plan
if: needs.terraform-plan.outputs.plan-exit-code == '0'
steps:
- name: Check author is approved
id: check-author
run: |
AUTHOR="${{ github.event.pull_request.user.login }}"
APPROVED_AUTHORS="marie-h,platform-bot,alice,bob"
if echo "$APPROVED_AUTHORS" | tr ',' '\n' | grep -qx "$AUTHOR"; then
echo "approved=true" >> "$GITHUB_OUTPUT"
else
echo "approved=false" >> "$GITHUB_OUTPUT"
fi
- name: Auto-approve and merge no-op plan
if: steps.check-author.outputs.approved == 'true'
env:
GH_TOKEN: ${{ secrets.PLATFORM_BOT_TOKEN }}
run: |
gh pr review ${{ github.event.pull_request.number }} \
--approve \
--body "Auto-approved: Terraform plan has no changes."
gh pr merge ${{ github.event.pull_request.number }} \
--auto \
--squash \
--delete-branch
The Approval Requirement Problem
GitHub branch protection rules require at least one approved review before merging. You can't merge a PR without an approval, even programmatically. This means you need an account that can leave an approving review.
We set up a dedicated GitHub bot account (platform-bot) and added it to our GitHub organization with write access to the Terraform repositories. The PLATFORM_BOT_TOKEN secret in the workflow above is a PAT for that account with repo scope.
The bot account is added as a required reviewer in our branch protection rules for the Terraform repos. The auto-merge job uses the bot's token to approve the PR, which satisfies the review requirement.
An alternative is using a GitHub App instead of a bot account — GitHub Apps can be granted PR review permissions and generate installation tokens via the API, which is more auditable and doesn't require a "ghost user" account. We went with the bot account for simplicity and may migrate to a GitHub App later.
Safety Checks
We don't auto-merge blindly. The conditions for auto-merge are:
Plan must be a clean no-op — exit code exactly 0. If there's any plan error, we fail the workflow and require human attention.
All other status checks must pass — this is handled by GitHub's branch protection rules. We require lint checks, security scans, and the plan check to all pass before merge is allowed. The --auto flag in gh pr merge means the merge happens when all required checks are green, not immediately when the approve fires.
PR author must be in the approved list — external contributors and bots with limited trust shouldn't get auto-merge. We maintain a list of trusted authors. For repos with external contributors, this is an important control.
PR targets main directly — auto-merge only applies to PRs targeting the default branch. Feature branches and environment promotion PRs go through normal review.
What Counts as a No-Op
A pure no-op means Terraform's state exactly matches the configuration being evaluated. This covers:
- Documentation or comment changes in
.tffiles - Adding or modifying
localsvalues that aren't consumed by resources - Refactoring module structure without changing resource definitions
- Updating CI configuration files in the same repository
- Adding or updating
.tflint.hcl,.terraform-docs.yaml, or similar tooling config
What is NOT a no-op even if it looks innocent:
- Changing a depends_on — Terraform will show a plan with a replace or update
- Changing lifecycle rules — triggers a plan
- Any actual resource change, obviously
We also occasionally see false no-ops: a plan that shows no changes today but would show changes tomorrow because it reads from external data sources. We haven't had a problem with this in practice, but it's worth knowing the edge case exists.
The Results
Before auto-merge: our Terraform PR queue averaged 18 PRs waiting for review. Median time-to-merge was around three days for non-urgent changes.
After auto-merge: roughly 60-65% of Terraform PRs are auto-merged within minutes of creation (once CI finishes). The remaining PRs that need human review are actual infrastructure changes. Reviewers have less queue fatigue and give those PRs better attention.
The secondary effect was that our infrastructure-changing PRs got faster reviews too. When every PR in the queue required reading a plan, reviewers triaged by "does this look risky" and often delayed low-risk-seeming plans. With no-op plans gone, the remaining queue was all changes, which made the prioritization easier.
One engineer told me he had stopped dreading the Terraform review rotation. I'll take that as a win.