diff --git a/.github/workflows/tf-terragrunt-drift.yml b/.github/workflows/tf-terragrunt-drift.yml
new file mode 100644
index 00000000..38a462a8
--- /dev/null
+++ b/.github/workflows/tf-terragrunt-drift.yml
@@ -0,0 +1,556 @@
+name: Terragrunt Drift Detection
+
+on:
+ workflow_call:
+ inputs:
+ env_name:
+ description: "Environment name (e.g. dev, qa, prod)"
+ required: false
+ type: string
+
+ working_directory:
+ description: "Path to the Terragrunt root directory"
+ required: true
+ type: string
+
+ terraform_version:
+ description: "Terraform version"
+ required: false
+ type: string
+ default: "1.14.9"
+
+ terragrunt_version:
+ description: "Terragrunt version"
+ required: false
+ type: string
+ default: "1.1.0"
+
+ queue_exclude_dirs:
+ description: "Comma-separated list of directories to exclude"
+ required: false
+ type: string
+ default: ""
+
+ create_credentials_file:
+ description: "Create a credentials file for GCP authentication"
+ required: false
+ type: boolean
+ default: true
+
+ token_format:
+ description: "GCP authentication token format"
+ required: false
+ type: string
+ default: "access_token"
+
+ access_token_lifetime:
+ description: "GCP access token lifetime"
+ required: false
+ type: string
+ default: "3600s"
+
+ provider:
+ description: "Cloud provider"
+ required: true
+ type: string
+
+ aws_region:
+ description: "AWS region"
+ required: false
+ type: string
+ default: "eu-west-1"
+
+ secrets:
+ GCP_PROJECT_ID:
+ description: "GCP Project ID"
+ required: false
+
+ GCP_CREDENTIALS:
+ required: false
+ description: 'GCP service account credentials in JSON format, used for authenticating Terraform to GCP'
+
+ WORKLOAD_IDENTITY_PROVIDER:
+ required: false
+ description: 'Optional Workload Identity Provider for GCP authentication'
+
+ SERVICE_ACCOUNT:
+ required: false
+ description: 'Optional service account email for workload identity authentication'
+
+ env-vars:
+ required: false
+ description: 'Multiline block of environment variables to pass to Terraform'
+
+ SLACK_WEBHOOK_URL:
+ description: "Slack Incoming Webhook URL"
+ required: false
+
+ AWS_ACCESS_KEY_ID:
+ required: false
+
+ AWS_SECRET_ACCESS_KEY:
+ required: false
+
+ AWS_SESSION_TOKEN:
+ required: false
+
+ BUILD_ROLE:
+ required: false
+
+ AZURE_CREDENTIALS:
+ required: false
+ description: "Azure service principal credentials in JSON format"
+
+
+ outputs:
+ status:
+ description: "clean | drift | error"
+ value: ${{ jobs.drift.outputs.status }}
+
+ drift_count:
+ value: ${{ jobs.drift.outputs.drift_count }}
+
+ error_count:
+ value: ${{ jobs.drift.outputs.error_count }}
+
+permissions:
+ contents: read
+ id-token: write
+
+concurrency:
+ group: tg-drift-gcp-${{ inputs.env_name }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ drift:
+ name: ${{ inputs.env_name }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ outputs:
+ status: ${{ steps.plan.outputs.status }}
+ drift_count: ${{ steps.plan.outputs.drift_count }}
+ error_count: ${{ steps.plan.outputs.error_count }}
+
+ env:
+ TG_PROVIDER_CACHE: "1"
+ TG_TF_PATH: terraform
+ TG_NON_INTERACTIVE: "true"
+ TG_NO_COLOR: "true"
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ inputs.terraform_version }}
+ terraform_wrapper: false
+
+ - name: Install Terragrunt
+ run: |
+ set -euo pipefail
+
+ VERSION=${{ inputs.terragrunt_version }}
+
+ curl -L \
+ https://github.com/gruntwork-io/terragrunt/releases/download/v${VERSION}/terragrunt_linux_amd64 \
+ -o terragrunt
+
+ chmod +x terragrunt
+ sudo mv terragrunt /usr/local/bin/
+
+ terragrunt --version
+
+
+ - name: ๐ฑ Set environment variables
+ env:
+ ENV_VARS: ${{ secrets.env-vars }}
+ run: |
+ if [ -n "$ENV_VARS" ]; then
+ (
+ cat <<'_EOT'
+ $ENV_VARS
+ _EOT
+ ) >> "$GITHUB_ENV"
+ fi
+
+ - name: ๐ฆ Configure AWS credentials
+ if: ${{ inputs.provider == 'aws' }}
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
+ role-to-assume: ${{ secrets.BUILD_ROLE }}
+ aws-region: ${{ inputs.aws_region }}
+ role-duration-seconds: 900
+ role-skip-session-tagging: true
+
+ - name: โ๏ธ Install Azure CLI
+ if: ${{ inputs.provider == 'azurerm' }}
+ uses: azure/login@v3
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: ๐ Authenticate to GCP
+ if: ${{ inputs.provider == 'gcp' }}
+ uses: google-github-actions/auth@v2
+ with:
+ credentials_json: ${{ secrets.GCP_CREDENTIALS }}
+ create_credentials_file: ${{ inputs.create_credentials_file }}
+ token_format: ${{ inputs.token_format }}
+ workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ secrets.SERVICE_ACCOUNT }}
+ access_token_lifetime: ${{ inputs.access_token_lifetime }}
+ project_id: ${{ inputs.GCP_PROJECT_ID }}
+
+
+ ###############################################################
+ # Install gcloud
+ ###############################################################
+
+ - name: Setup gcloud SDK
+ if: ${{ inputs.provider == 'gcp' }}
+ uses: google-github-actions/setup-gcloud@v2
+ with:
+ project_id: ${{ secrets.GCP_PROJECT_ID }}
+
+ ###############################################################
+ # Terragrunt Drift Detection
+ ###############################################################
+
+ - name: Run Terragrunt Plan
+ id: plan
+ working-directory: ${{ inputs.working_directory }}
+ run: |
+ set +e
+
+ terragrunt run --all plan -- \
+ -lock=false \
+ -input=false \
+ -no-color \
+ -detailed-exitcode \
+ 2>&1 | tee "${GITHUB_WORKSPACE}/plan.log"
+
+ rc=${PIPESTATUS[0]}
+ set -e
+
+ echo "Terragrunt exit code: $rc"
+
+ ############################################################
+ # Clean output (remove ANSI + Terragrunt prefixes)
+ ############################################################
+
+ sed -E \
+ 's/\x1b\[[0-9;]*m//g; s/^[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+ (STDOUT|STDERR) //' \
+ "${GITHUB_WORKSPACE}/plan.log" \
+ > "${GITHUB_WORKSPACE}/plan.clean.log"
+
+ ############################################################
+ # Prepare output files
+ ############################################################
+
+ touch "${GITHUB_WORKSPACE}/drifted.tsv"
+ touch "${GITHUB_WORKSPACE}/errored.tsv"
+
+ ############################################################
+ # Parse drift
+ ############################################################
+
+ grep -E '\] terraform: Plan: [0-9]+ to add, [0-9]+ to change, [0-9]+ to destroy' \
+ "${GITHUB_WORKSPACE}/plan.clean.log" \
+ | grep -vE 'Plan: 0 to add, 0 to change, 0 to destroy' \
+ | sed -E \
+ 's/^\[([^]]+)\] terraform: Plan: ([0-9]+) to add, ([0-9]+) to change, ([0-9]+) to destroy.*/\1\t+\2 ~\3 -\4/' \
+ | sort -u \
+ > "${GITHUB_WORKSPACE}/drifted.tsv" || true
+
+ ############################################################
+ # Parse errors
+ ############################################################
+
+ grep -E '\] terraform: Error:' \
+ "${GITHUB_WORKSPACE}/plan.clean.log" \
+ | sed -E \
+ 's/^\[([^]]+)\] terraform: Error: (.*)/\1\t\2/' \
+ | sort -u \
+ > "${GITHUB_WORKSPACE}/errored.tsv" || true
+
+ ############################################################
+ # Count results
+ ############################################################
+
+ drift_count=$(awk 'END{print NR+0}' "${GITHUB_WORKSPACE}/drifted.tsv")
+ error_count=$(awk 'END{print NR+0}' "${GITHUB_WORKSPACE}/errored.tsv")
+
+ ############################################################
+ # Determine workflow status
+ ############################################################
+
+ if [[ "$error_count" -gt 0 || "$rc" == "1" ]]; then
+ status="error"
+ elif [[ "$drift_count" -gt 0 || "$rc" == "2" ]]; then
+ status="drift"
+ else
+ status="clean"
+ fi
+
+ ############################################################
+ # Parser safety check
+ ############################################################
+
+ if [[ "$status" == "clean" ]] && \
+ [[ -s "${GITHUB_WORKSPACE}/plan.clean.log" ]] && \
+ ! grep -qE '\] terraform: (Plan: [0-9]|No changes|Error:)' "${GITHUB_WORKSPACE}/plan.clean.log"; then
+
+ echo "::warning::No recognizable Terraform output found. Parser may need updating."
+ status="error"
+ fi
+
+ ############################################################
+ # Outputs
+ ############################################################
+
+ {
+ echo "status=$status"
+ echo "drift_count=$drift_count"
+ echo "error_count=$error_count"
+ } >> "$GITHUB_OUTPUT"
+
+ ############################################################
+ # Console summary
+ ############################################################
+
+ echo ""
+ echo "==============================="
+ echo "Status : $status"
+ echo "Drift Count : $drift_count"
+ echo "Error Count : $error_count"
+ echo "==============================="
+
+ ############################################################
+ # GitHub Step Summary
+ ############################################################
+
+ {
+ echo "## Terragrunt Drift Detection โ ${{ inputs.env_name }}"
+ echo ""
+ echo "**Status:** \`${status}\` ยท **Drifted:** ${drift_count} ยท **Errored:** ${error_count}"
+ echo ""
+
+ if [ "${drift_count}" -gt 0 ]; then
+ echo "### :pencil2: Drifted units"
+
+ while IFS="$(printf '\t')" read -r unit counts; do
+ [ -z "${unit}" ] && continue
+
+ echo "${unit} ${counts}
"
+ echo ""
+ echo '```hcl'
+ grep -F "[${unit}] terraform:" "${GITHUB_WORKSPACE}/plan.clean.log" \
+ | sed -E "s/^\[[^]]+\] terraform: ?//"
+ echo '```'
+ echo ""
+ echo " "
+
+ done < "${GITHUB_WORKSPACE}/drifted.tsv"
+ fi
+
+ if [ "${error_count}" -gt 0 ]; then
+ echo ""
+ echo "### :rotating_light: Errored units"
+
+ while IFS="$(printf '\t')" read -r unit msg; do
+ [ -z "${unit}" ] && continue
+
+ echo "${unit} โ ${msg}
"
+ echo ""
+ echo '```'
+ grep -F "[${unit}] terraform:" "${GITHUB_WORKSPACE}/plan.clean.log" \
+ | sed -E "s/^\[[^]]+\] terraform: ?//"
+ echo '```'
+ echo ""
+ echo " "
+
+ done < "${GITHUB_WORKSPACE}/errored.tsv"
+ fi
+
+ } >> "$GITHUB_STEP_SUMMARY"
+
+
+ - name: Notify Slack (drift or error only)
+ if: ${{ steps.plan.outputs.status != 'clean' }}
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ ENV_NAME: ${{ inputs.env_name }}
+ STATUS: ${{ steps.plan.outputs.status }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ DRIFT_COUNT: ${{ steps.plan.outputs.drift_count }}
+ ERROR_COUNT: ${{ steps.plan.outputs.error_count }}
+ SLACK_ICON_URL: https://clouddrove.com/assets/images/logo.png
+ REPO: ${{ github.repository }}
+ REF_NAME: ${{ github.ref_name }}
+
+ run: |
+ set -euo pipefail
+
+ if [ "${STATUS}" = "error" ]; then
+ EMOJI=":rotating_light:"
+ HEADER="Infrastructure drift check failed"
+ else
+ EMOJI=":warning:"
+ HEADER="Infrastructure drift detected"
+ fi
+
+ payload=$(jq -n \
+ --arg emoji "$EMOJI" \
+ --arg header "$HEADER" \
+ --arg icon "$SLACK_ICON_URL" \
+ --arg env "$ENV_NAME" \
+ --arg status "$STATUS" \
+ --arg drift "$DRIFT_COUNT" \
+ --arg error "$ERROR_COUNT" \
+ --arg run "$RUN_URL" \
+ --arg repo "$REPO" \
+ --arg ref "$REF_NAME" \
+ --rawfile drifted "${GITHUB_WORKSPACE}/drifted.tsv" \
+ --rawfile errored "${GITHUB_WORKSPACE}/errored.tsv" \
+ '
+ def cap($x):
+ if ($x | length) > 2800
+ then ($x[0:2750] + "\n... (truncated - see run)")
+ else $x
+ end;
+
+ def fmt($s; $sep):
+ ($s | rtrimstr("\n")) as $t
+ | if ($t | length) == 0 then []
+ else
+ ($t
+ | split("\n")
+ | map(
+ split("\t")
+ | "- `\(.[0])`\($sep)\(.[1] | if length > 140 then .[0:140] + "..." else . end)"
+ ))
+ end;
+
+ {
+ blocks: (
+ [
+ {
+ type: "header",
+ text: {
+ type: "plain_text",
+ emoji: true,
+ text: "\($emoji) \($header) - \($env)"
+ }
+ },
+ {
+ type: "section",
+ fields: (
+ (if ($env | length) > 0
+ then [
+ {
+ type: "mrkdwn",
+ text: "*Environment*\n`\($env)`"
+ }
+ ]
+ else []
+ end)
+ + [
+ {
+ type: "mrkdwn",
+ text: "*Status*\n`\($status)`"
+ },
+ {
+ type: "mrkdwn",
+ text: "*Drifted*\n\($drift) unit(s)"
+ },
+ {
+ type: "mrkdwn",
+ text: "*Errored*\n\($error) unit(s)"
+ }
+ ]
+ )
+ }
+ ]
+
+ + (
+ fmt($drifted; " ") as $d
+ | if ($d | length) == 0 then []
+ else [
+ { type: "divider" },
+ {
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: cap(
+ ":pencil2: *Drifted units* _(+add ~change -destroy)_\n"
+ + ($d | join("\n"))
+ )
+ }
+ }
+ ]
+ end
+ )
+
+ + (
+ fmt($errored; " - ") as $e
+ | if ($e | length) == 0 then []
+ else [
+ { type: "divider" },
+ {
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: cap(
+ ":rotating_light: *Errored units*\n"
+ + ($e | join("\n"))
+ )
+ }
+ }
+ ]
+ end
+ )
+
+ + [
+ {
+ type: "context",
+ elements: [
+ {
+ type: "mrkdwn",
+ text: "Infrastructure Drift Detection - `\($repo)@\($ref)` - read-only plan"
+ }
+ ]
+ },
+ {
+ type: "actions",
+ elements: [
+ {
+ type: "button",
+ text: {
+ type: "plain_text",
+ text: "View run"
+ },
+ url: $run,
+ style: "primary"
+ }
+ ]
+ }
+ ]
+ )
+ }')
+
+ code=$(curl -sS \
+ -o /tmp/slack_resp \
+ -w '%{http_code}' \
+ -X POST \
+ -H 'Content-type: application/json' \
+ --data "$payload" \
+ "$SLACK_WEBHOOK_URL")
+
+ echo "Slack HTTP ${code}: $(cat /tmp/slack_resp)"
+
+ [ "${code}" = "200" ]
\ No newline at end of file
diff --git a/docs/tf-terragrunt-drift.md b/docs/tf-terragrunt-drift.md
new file mode 100644
index 00000000..4f56742a
--- /dev/null
+++ b/docs/tf-terragrunt-drift.md
@@ -0,0 +1,54 @@
+## [Terragrunt Drift Detection Workflow](https://github.com/clouddrove/github-shared-workflows/blob/master/.github/workflows/tf-terragrunt-drift.yml)
+
+This workflow automates **Terraform/Terragrunt infrastructure drift detection** by running a read-only `terragrunt plan` against the live infrastructure and identifying resources that have changed outside of the Terraform configuration.
+
+The reusable workflow is stored at `.github/workflows/tf-terragrunt-drift.yml` in the shared workflows repository.
+
+### Key capabilities
+
+- Detect infrastructure drift using `terragrunt plan`.
+- Supports **AWS, Azure, and GCP**.
+- Select the cloud provider using the `provider` input.
+- Supports reusable workflow execution through `workflow_call`.
+- Reports drifted and errored Terragrunt units.
+- Provides workflow status:
+ - `clean` โ no drift detected.
+ - `drift` โ infrastructure drift detected.
+ - `error` โ Terraform/Terragrunt execution failed.
+- Publishes a detailed GitHub Actions step summary.
+- Sends a Slack notification when drift or an error is detected.
+- Uses `-lock=false`, so the drift check does not acquire the Terraform state lock.
+
+### Example
+
+```yaml
+name: TF-Drift
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ drift:
+ uses: clouddrove/github-shared-workflows/.github/workflows/tf-terragrunt-drift.yml@v2
+
+ with:
+ working_directory: live/dev
+ env_name: dev
+ provider: gcp
+ terraform_version: "1.14.9"
+ terragrunt_version: "1.1.0"
+
+ secrets:
+ GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
+ GCP_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+```
\ No newline at end of file