Blog
October 3, 2023 Marie H.

Auto-Merging Terraform PRs with No-Op Plans

Auto-Merging Terraform PRs with No-Op Plans

Photo by <a href="https://unsplash.com/@prostotakphoto?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Mykyta Martynenko</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

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 .tf files
  • Adding or modifying locals values 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.