Skip to content

Instantly share code, notes, and snippets.

@r5b9
Created April 10, 2026 12:38
Show Gist options
  • Select an option

  • Save r5b9/583a1e657850915ee5aa25900ebe3fd7 to your computer and use it in GitHub Desktop.

Select an option

Save r5b9/583a1e657850915ee5aa25900ebe3fd7 to your computer and use it in GitHub Desktop.
name: Plan Terraform Changes
on:
workflow_call:
inputs:
environment_name:
required: true
type: string
working_directory:
required: true
type: string
jobs:
plan:
runs-on: ubuntu-latest
# Run all subsequent commands in the specified directory
defaults:
run:
working-directory: ${{ inputs.working_directory }}
environment:
name: ${{ inputs.environment_name }}
# Grant permissions for the OIDC token
permissions:
id-token: write
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Resolve Terraform Version
id: tfver
uses: ./.github/actions/resolve-terraform-version
- name: Setup SSH key for private modules
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.TERRAFORM_MODULES_SSH_KEY }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
# Use the role ARN from the environment secret
role-to-assume: ${{ secrets.TERRAFORM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ steps.tfver.outputs.version }}
terraform_wrapper: false
- name: Debug Inputs
run: |
echo "Received Environment Name: ${{ inputs.environment_name }}"
echo "Received Working Directory: ${{ inputs.working_directory }}"
- name: Terraform Init
# Use an absolute path to the backend config at the repo root
run: terraform init -upgrade -backend-config=${{ github.workspace }}/backend-${{ inputs.environment_name }}.hcl
- name: Terraform Plan
id: plan
continue-on-error: true # Allow the workflow to continue on non-zero exit codes
# The var-file is relative to the working directory
run: |
set +e # Temporarily disable exit-on-error
terraform plan -var-file=./${{ inputs.environment_name }}.tfvars -no-color -lock=false -detailed-exitcode -out=tfplan > plan.log 2>&1
PLAN_EXIT_CODE=$?
set -e # Re-enable exit-on-error for the rest of the script
echo "Terraform plan completed with exit code ${PLAN_EXIT_CODE}"
cat plan.log
echo "exit_code=${PLAN_EXIT_CODE}" >> $GITHUB_OUTPUT
# Exit with 0 for success (0) or changes (2), but fail for errors (1)
[[ "${PLAN_EXIT_CODE}" == "1" ]] && exit 1 || exit 0
- name: Terraform Plan Exit Code
run: echo "Terraform plan exited with code ${{ steps.plan.outputs.exit_code }}"
- name: Prepare plan output for comment
id: prep-comment
if: always()
run: |
if [[ "${{ steps.plan.outputs.exit_code }}" != '1' ]]; then
terraform show -no-color tfplan > output
terraform show -json tfplan > plan.json
else
cat plan.log > output
fi
- name: Check for Destructive Changes
id: check_destructive_changes
if: steps.plan.outputs.exit_code == '2'
run: |
REPLACED_RESOURCES=$(jq -r '[.resource_changes[] | select(.change.actions | contains(["create"]) and contains(["delete"])) | .address] | map("-/+ " + .) | join("\n")' plan.json)
DELETED_RESOURCES=$(jq -r '[.resource_changes[] | select(.change.actions == ["delete"]) | .address] | map("- " + .) | join("\n")' plan.json)
MESSAGE_ITEMS=""
if [[ -n "$REPLACED_RESOURCES" ]]; then
MESSAGE_ITEMS+="${REPLACED_RESOURCES}"
fi
if [[ -n "$DELETED_RESOURCES" ]]; then
if [[ -n "$MESSAGE_ITEMS" ]]; then
MESSAGE_ITEMS+=$'\n'
fi
MESSAGE_ITEMS+="${DELETED_RESOURCES}"
fi
if [[ -n "$REPLACED_RESOURCES" ]]; then
echo "has_destructive_changes=true" >> "$GITHUB_OUTPUT"
echo "message=The following resources will be recreated (destroyed and created):" >> "$GITHUB_OUTPUT"
echo "message_type=error" >> "$GITHUB_OUTPUT"
echo "message_items<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$MESSAGE_ITEMS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
elif [[ -n "$DELETED_RESOURCES" ]]; then
echo "has_destructive_changes=false" >> "$GITHUB_OUTPUT"
echo "message=The following resources will be deleted:" >> "$GITHUB_OUTPUT"
echo "message_type=warning" >> "$GITHUB_OUTPUT"
echo "message_items<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$MESSAGE_ITEMS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
fi
- name: Set Success Tip
id: success_tip
if: steps.plan.outputs.exit_code == '2' && steps.check_destructive_changes.outputs.has_destructive_changes != 'true'
run: |
echo "message_type=tip" >> "$GITHUB_OUTPUT"
echo "message=Plan successful with non-destructive changes." >> "$GITHUB_OUTPUT"
- name: Set No Changes Note
id: no_changes_note
if: steps.plan.outputs.exit_code == '0'
run: |
echo "message_type=note" >> "$GITHUB_OUTPUT"
echo "message=No changes detected in the plan." >> "$GITHUB_OUTPUT"
- name: Create Plan Comment Body
uses: ./.github/actions/pr-comment
if: always()
with:
path: ${{ inputs.working_directory }}
environment: ${{ inputs.environment_name }}
title: "Terraform Plan"
input_file: output
output_file: pr-comment
# Conditionally set the message and items
message: ${{ steps.check_destructive_changes.outputs.message || steps.no_changes_note.outputs.message || steps.success_tip.outputs.message }}
message_type: ${{ steps.check_destructive_changes.outputs.message_type || steps.no_changes_note.outputs.message_type || steps.success_tip.outputs.message_type }}
message_items: ${{ steps.check_destructive_changes.outputs.message_items }}
- name: Ensure Plan Header (for quoting)
if: always()
run: |
set +e
if [ ! -f pr-comment ]; then
echo "pr-comment file missing (earlier step may have failed); creating empty placeholder.";
touch pr-comment
fi
if ! grep -Eq '^### Terraform Plan for ' pr-comment 2>/dev/null; then
echo "Prepending canonical header line (repo prefix removed)";
TMP=$(mktemp)
echo "### Terraform Plan for ${{ inputs.working_directory }}/${{ inputs.environment_name }}" > "$TMP"
cat pr-comment >> "$TMP"
mv "$TMP" pr-comment
else
echo "Header already present; no action needed.";
fi
exit 0
- name: Add destructive-changes label
if: steps.plan.outputs.exit_code == '2' && (steps.check_destructive_changes.outputs.message_type == 'error' || steps.check_destructive_changes.outputs.message_type == 'warning')
uses: actions/github-script@v7
with:
script: |
const LABEL_NAME = 'destructive-changes';
const LABEL_COLOR = 'b60205'; // red
// Ensure label exists
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: LABEL_NAME,
color: LABEL_COLOR,
description: 'Terraform plan includes deletions or replacements'
});
} catch (error) {
if (error.status !== 422) throw error; // already exists
}
// Add label to PR
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [LABEL_NAME]
});
- name: Remove destructive-changes label when safe
if: steps.plan.outputs.exit_code == '0' || (steps.plan.outputs.exit_code == '2' && steps.check_destructive_changes.outputs.message_type != 'error' && steps.check_destructive_changes.outputs.message_type != 'warning')
uses: actions/github-script@v7
with:
script: |
const LABEL_NAME = 'destructive-changes';
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: LABEL_NAME,
});
} catch (error) {
if (error.status !== 404) throw error; // ignore if label not present
}
- name: Comment PR
uses: thollander/actions-comment-pull-request@v3
if: always()
with:
file-path: pr-comment
comment-tag: terraform-plan-${{ inputs.working_directory }}-${{ inputs.environment_name }}
- name: Fail on Destructive Changes
if: steps.check_destructive_changes.outputs.has_destructive_changes == 'true'
run: |
echo "::warning::Terraform plan includes destructive changes (replacements). Proceeding with plan so it can be reviewed and approved before apply."
- name: Sanitize artifact name
id: sanitize
uses: ./.github/actions/sanitize-name
with:
value: ${{ inputs.working_directory }}
- name: Save Artifact
id: save-artifact
uses: actions/upload-artifact@v4
if: steps.plan.outputs.exit_code == '2'
with:
name: ${{ steps.sanitize.outputs.safe_name }}-${{ inputs.environment_name }}-tfplan
path: ${{ inputs.working_directory }}/tfplan
retention-days: 7
if-no-files-found: error
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment