Skip to main content

git-ape-destroy


title: "Git-Ape: Destroy" sidebar_label: "Destroy" description: "GitHub Actions workflow: Git-Ape: Destroy"

Git-Ape: Destroy

Workflow file: .github/workflows/git-ape-destroy.yml

Triggers

  • push — branches: ["main"] — paths: .azure/deployments/**/metadata.json, .azure/deployments/**/state.json
  • workflow_dispatch

Permissions

  • id-token: write
  • contents: write
  • issues: write
  • pull-requests: write

Jobs

detect-destroys

PropertyValue
Display NameDetect destroy requests
Runs Onubuntu-latest
Steps2

destroy

PropertyValue
Display NameDestroy: ${{ matrix.deployment_id }}
Runs Onubuntu-latest
Environmentazure-destroy
Depends Ondetect-destroys
Steps8

Source

Click to view full workflow YAML
# Git-Ape Destroy Workflow
# Triggers on:
# 1. PR merge to main that sets metadata.json status to "destroy-requested"
# 2. Manual workflow dispatch (emergency fallback)
# Deletes the Azure resource group for a tracked deployment.

name: "Git-Ape: Destroy"

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

on:
push:
branches: [main]
paths:
- ".azure/deployments/**/metadata.json"
- ".azure/deployments/**/state.json"

workflow_dispatch:
inputs:
deployment_id:
description: "Deployment ID (e.g., deploy-20260218-220000)"
required: true
type: string
confirm:
description: "Type 'destroy' to confirm"
required: true
type: string

permissions:
id-token: write # OIDC token for Azure login
contents: write # Commit updated state files
issues: write # Post result on linked issues
pull-requests: write # Post result on merged PR

concurrency:
group: git-ape-destroy-${{ github.sha }}
cancel-in-progress: false # Never cancel in-progress destroys

jobs:
detect-destroys:
name: Detect destroy requests
runs-on: ubuntu-latest
outputs:
deployment_ids: ${{ steps.find.outputs.deployment_ids }}
has_destroys: ${{ steps.find.outputs.has_destroys }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2

- name: Find destroy-requested deployments
id: find
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
CONFIRM="${{ inputs.confirm }}"
if [[ "$CONFIRM" != "destroy" ]]; then
echo "::error::Confirmation must be 'destroy'"
echo "has_destroys=false" >> "$GITHUB_OUTPUT"
echo "deployment_ids=[]" >> "$GITHUB_OUTPUT"
exit 1
fi
DEPLOYMENT_IDS='["${{ inputs.deployment_id }}"]'
echo "has_destroys=true" >> "$GITHUB_OUTPUT"
echo "deployment_ids=$DEPLOYMENT_IDS" >> "$GITHUB_OUTPUT"
echo "Manual destroy requested: ${{ inputs.deployment_id }}"
exit 0
fi

# On push: find deployments where metadata.json changed and status is destroy-requested
CHANGED_FILES=$(git diff --name-only HEAD~1...HEAD -- '.azure/deployments/*/metadata.json' '.azure/deployments/*/state.json' 2>/dev/null || true)

if [[ -z "$CHANGED_FILES" ]]; then
echo "has_destroys=false" >> "$GITHUB_OUTPUT"
echo "deployment_ids=[]" >> "$GITHUB_OUTPUT"
echo "No deployment metadata changes found"
exit 0
fi

# Extract unique deployment IDs from changed files
DEPLOY_DIRS=$(echo "$CHANGED_FILES" | sed 's|.azure/deployments/\([^/]*\)/.*|\1|' | sort -u)

# Filter to only those with status "destroy-requested"
DESTROY_IDS=""
for DIR in $DEPLOY_DIRS; do
METADATA=".azure/deployments/$DIR/metadata.json"
if [[ -f "$METADATA" ]]; then
STATUS=$(jq -r '.status // ""' "$METADATA")
if [[ "$STATUS" == "destroy-requested" ]]; then
DESTROY_IDS="$DESTROY_IDS $DIR"
echo "Found destroy request: $DIR"
fi
fi
done

if [[ -z "$DESTROY_IDS" ]]; then
echo "has_destroys=false" >> "$GITHUB_OUTPUT"
echo "deployment_ids=[]" >> "$GITHUB_OUTPUT"
echo "No destroy-requested deployments found"
exit 0
fi

DEPLOYMENT_IDS=$(echo "$DESTROY_IDS" | tr ' ' '\n' | grep -v '^$' | jq -R -s -c 'split("\n") | map(select(. != ""))')
echo "has_destroys=true" >> "$GITHUB_OUTPUT"
echo "deployment_ids=$DEPLOYMENT_IDS" >> "$GITHUB_OUTPUT"
echo "Deployments to destroy: $DEPLOYMENT_IDS"

destroy:
name: "Destroy: ${{ matrix.deployment_id }}"
needs: detect-destroys
if: needs.detect-destroys.outputs.has_destroys == 'true'
runs-on: ubuntu-latest
environment: azure-destroy
strategy:
matrix:
deployment_id: ${{ fromJson(needs.detect-destroys.outputs.deployment_ids) }}
max-parallel: 1
fail-fast: false
steps:
- uses: actions/checkout@v6

- name: Load deployment state
id: state
run: |
DEPLOYMENT_ID="${{ matrix.deployment_id }}"
STATE_FILE=".azure/deployments/$DEPLOYMENT_ID/state.json"

if [[ ! -f "$STATE_FILE" ]]; then
echo "::error::Deployment state not found: $STATE_FILE"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 1
fi

# Stacks-only: stackId is the single source of truth. If it's missing
# this deployment wasn't created via Deployment Stacks and can't be
# destroyed by this workflow.
STACK_ID=$(jq -r '.stackId // empty' "$STATE_FILE")
STACK_NAME=$(jq -r '.deploymentId // empty' "$STATE_FILE")
RG_NAME=$(jq -r '.resourceGroup // empty' "$STATE_FILE")

if [[ -z "$STACK_ID" && -z "$STACK_NAME" ]]; then
echo "::error::state.json has no stackId or deploymentId — cannot destroy"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 1
fi

echo "found=true" >> "$GITHUB_OUTPUT"
echo "stack_id=$STACK_ID" >> "$GITHUB_OUTPUT"
echo "stack_name=$STACK_NAME" >> "$GITHUB_OUTPUT"
echo "resource_group=$RG_NAME" >> "$GITHUB_OUTPUT"
echo "Will destroy deployment stack: $STACK_NAME (${STACK_ID:-by name})"

- name: Azure Login (OIDC)
if: steps.state.outputs.found == 'true'
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Inventory managed resources
id: check
if: steps.state.outputs.found == 'true'
run: |
STACK_NAME="${{ steps.state.outputs.stack_name }}"

# Read live managed-resource list from the stack itself.
# Stacks are idempotent: if the stack is already gone we record that and exit cleanly.
if ! STACK_JSON=$(az stack sub show --name "$STACK_NAME" --output json 2>/dev/null); then
echo "Stack $STACK_NAME not found (already destroyed?)"
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "resource_count=0" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "exists=true" >> "$GITHUB_OUTPUT"

RESOURCES=$(echo "$STACK_JSON" | jq -c '[(.resources // [])[] | {id: .id, status: .status}]')
COUNT=$(echo "$RESOURCES" | jq 'length')

echo "resource_count=$COUNT" >> "$GITHUB_OUTPUT"
echo "resources<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESOURCES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

echo ""
echo "=== Destroy Plan ==="
echo "Stack: $STACK_NAME"
echo "Managed resources: $COUNT"
echo "$RESOURCES" | jq -r '.[] | " - \(.id) [\(.status)]"'
echo "==================="

- name: Delete deployment stack
id: destroy
if: steps.check.outputs.exists == 'true'
run: |
STACK_NAME="${{ steps.state.outputs.stack_name }}"
echo "🗑️ Deleting deployment stack: $STACK_NAME"
echo " --action-on-unmanage deleteAll — removes every resource (across RGs / sub scope) the stack manages"
echo " This will block until all managed resources are fully deleted..."

START_TIME=$(date +%s)

# --bypass-stack-out-of-sync-error: a destroyed run is one-shot; we
# don't need the safety check that protects against stale manifests
# during iterative updates.
if ! az stack sub delete \
--name "$STACK_NAME" \
--action-on-unmanage deleteAll \
--bypass-stack-out-of-sync-error true \
--yes 2>&1; then
echo "destroy_status=failed" >> "$GITHUB_OUTPUT"
echo "::error::Failed to delete deployment stack $STACK_NAME"
exit 1
fi

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "destroy_status=succeeded" >> "$GITHUB_OUTPUT"
echo "destroy_duration=${DURATION}s" >> "$GITHUB_OUTPUT"
echo "✅ Stack deleted in ${DURATION}s: $STACK_NAME"

- name: Update deployment state
if: always() && steps.state.outputs.found == 'true'
run: |
DEPLOYMENT_ID="${{ matrix.deployment_id }}"
DEPLOY_DIR=".azure/deployments/$DEPLOYMENT_ID"
STATE_FILE="$DEPLOY_DIR/state.json"
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)

if [[ "${{ steps.check.outputs.exists }}" == "false" ]]; then
STATUS="already-destroyed"
elif [[ "${{ steps.destroy.outputs.destroy_status }}" == "succeeded" ]]; then
STATUS="destroyed"
else
STATUS="destroy-failed"
fi

# Update state file
if [[ -f "$STATE_FILE" ]]; then
jq --arg status "$STATUS" --arg ts "$TIMESTAMP" --arg actor "${{ github.actor }}" \
--arg duration "${{ steps.destroy.outputs.destroy_duration }}" \
'. + {status: $status, destroyedAt: $ts, destroyedBy: $actor, destroyDuration: $duration}' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi

# Update metadata.json status
if [[ -f "$DEPLOY_DIR/metadata.json" ]]; then
jq --arg status "$STATUS" '.status = $status' \
"$DEPLOY_DIR/metadata.json" > "$DEPLOY_DIR/metadata.json.tmp" \
&& mv "$DEPLOY_DIR/metadata.json.tmp" "$DEPLOY_DIR/metadata.json"
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$DEPLOY_DIR/state.json" "$DEPLOY_DIR/metadata.json"
git diff --cached --quiet || git commit -m "git-ape: mark ${{ matrix.deployment_id }} as $STATUS"
git push || echo "::warning::Could not push state update"

- name: Post summary
if: always()
run: |
DEPLOY_ID="${{ matrix.deployment_id }}"
STACK="${{ steps.state.outputs.stack_name }}"
RG="${{ steps.state.outputs.resource_group }}"
STATUS="${{ steps.destroy.outputs.destroy_status }}"
DURATION="${{ steps.destroy.outputs.destroy_duration }}"
RESOURCE_COUNT="${{ steps.check.outputs.resource_count }}"
EXISTS="${{ steps.check.outputs.exists }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

echo "============================================"
echo "Git-Ape Destroy Summary"
echo "============================================"
echo "Deployment: $DEPLOY_ID"
echo "Stack: $STACK"
echo "Resource Group: $RG"
if [[ "$EXISTS" == "false" ]]; then
echo "Result: Already destroyed (stack not found)"
elif [[ "$STATUS" == "succeeded" ]]; then
echo "Result: ✅ Destroyed ($RESOURCE_COUNT managed resources)"
echo "Duration: $DURATION"
else
echo "Result: ❌ Failed"
fi
echo "Run: $RUN_URL"
echo "============================================"

- name: Notify via Slack
if: always()
continue-on-error: true
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
if [[ -z "$SLACK_WEBHOOK_URL" ]]; then exit 0; fi

DEPLOY_ID="${{ matrix.deployment_id }}"
STACK="${{ steps.state.outputs.stack_name }}"
STATUS="${{ steps.destroy.outputs.destroy_status }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

if [[ "$STATUS" == "succeeded" ]]; then
EMOJI="🗑️"
MSG="Deployment stack *$STACK* ($DEPLOY_ID) destroyed"
else
EMOJI="❌"
MSG="Destroy failed for stack *$STACK* ($DEPLOY_ID)"
fi

curl -sf -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{
\"text\": \"$EMOJI $MSG\",
\"blocks\": [
{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"$EMOJI *Git-Ape Destroy: $DEPLOY_ID*\\n\\n$MSG\\n\\nTriggered by: ${{ github.actor }}\\n<$RUN_URL|View logs>\"
}
}
]
}" || echo "::warning::Slack notification failed"