Skip to main content

Git-Ape: Plan

Workflow file: .github/skills/git-ape-onboarding/templates/workflows/git-ape-plan.yml

info
Scaffolded by /git-ape-onboarding

This workflow is shipped as a template under .github/skills/git-ape-onboarding/templates/workflows/ and copied into your repository's .github/workflows/ by the /git-ape-onboarding flow. It does not run in the git-ape repo itself.

Triggersโ€‹

  • pull_request โ€” paths: .azure/deployments/**/template.json, .azure/deployments/**/parameters.json โ€” types: opened, synchronize

Permissionsโ€‹

  • actions: read
  • id-token: write
  • contents: read
  • pull-requests: write
  • security-events: write

Jobsโ€‹

detect-deploymentsโ€‹

PropertyValue
Display NameDetect changed deployments
Runs Onubuntu-latest
Steps2

plan-localโ€‹

PropertyValue
Display NamePlan Local: ${{ matrix.deployment_id }}
Runs Onubuntu-latest
Depends Ondetect-deployments
Steps12

plan-azureโ€‹

PropertyValue
Display NamePlan Azure: ${{ matrix.deployment_id }}
Runs Onubuntu-latest
Depends Ondetect-deployments
Steps8

plan-commentโ€‹

PropertyValue
Display NamePlan Comment: ${{ matrix.deployment_id }}
Runs Onubuntu-latest
Depends Ondetect-deployments, plan-local, plan-azure
Steps3

Sourceโ€‹

Click to view full workflow YAML
# Git-Ape Planning Workflow
# Triggers when a PR adds/modifies deployment artifacts under .azure/deployments/
# Validates the ARM template, runs what-if analysis, and posts the plan as a PR comment.

name: "Git-Ape: Plan"

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

on:
pull_request:
types: [opened, synchronize]
paths:
- ".azure/deployments/**/template.json"
- ".azure/deployments/**/parameters.json"

permissions:
actions: read # Download artifacts between jobs
id-token: write # OIDC token for Azure login
contents: read # Read repo contents
pull-requests: write # Post plan as PR comment
security-events: write # Upload SARIF results from template analyzer

concurrency:
group: git-ape-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
detect-deployments:
name: Detect changed deployments
runs-on: ubuntu-latest
outputs:
deployment_ids: ${{ steps.find.outputs.deployment_ids }}
has_deployments: ${{ steps.find.outputs.has_deployments }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Find deployment directories with changes
id: find
env:
# Route attacker-controllable context through env, never inline into the
# shell โ€” base_ref is part of the PR payload (GitHub Actions hardening).
BASE_REF: ${{ github.base_ref }}
run: |
# Find all deployment directories that have template.json changes in this PR
CHANGED_FILES=$(git diff --name-only "origin/${BASE_REF}...HEAD" -- '.azure/deployments/*/template.json' '.azure/deployments/*/parameters.json')

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

# Extract unique deployment IDs
DEPLOYMENT_IDS=$(echo "$CHANGED_FILES" | sed 's|.azure/deployments/\([^/]*\)/.*|\1|' | sort -u | jq -R -s -c 'split("\n") | map(select(. != ""))')

# Reject any deployment directory name outside a safe charset before it
# becomes a matrix value. matrix.deployment_id is derived from
# attacker-controllable PR directory names; constraining it to
# [A-Za-z0-9._-] guarantees it can never carry shell or expression
# metacharacters into downstream jobs (defense in depth on top of the
# env-passing used in every run/script block).
INVALID=$(echo "$DEPLOYMENT_IDS" | jq -r '.[] | select(test("^[A-Za-z0-9._-]+$") | not)')
if [[ -n "$INVALID" ]]; then
echo "::error::Invalid deployment directory name(s): $INVALID. Allowed characters: A-Z a-z 0-9 . _ -"
exit 1
fi

echo "has_deployments=true" >> "$GITHUB_OUTPUT"
echo "deployment_ids=$DEPLOYMENT_IDS" >> "$GITHUB_OUTPUT"
echo "Detected deployments: $DEPLOYMENT_IDS"

plan-local:
name: "Plan Local: ${{ matrix.deployment_id }}"
needs: detect-deployments
if: needs.detect-deployments.outputs.has_deployments == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
deployment_id: ${{ fromJson(needs.detect-deployments.outputs.deployment_ids) }}
fail-fast: false
# matrix.deployment_id is attacker-controllable (derived from PR directory
# names). Expose it as an environment variable so run/script blocks reference
# "$DEPLOYMENT_ID" instead of inlining ${{ ... }}, preventing script injection.
env:
DEPLOYMENT_ID: ${{ matrix.deployment_id }}

steps:
- uses: actions/checkout@v6

- name: Read deployment parameters
id: params
run: |
DEPLOY_DIR=".azure/deployments/$DEPLOYMENT_ID"

if [[ ! -f "$DEPLOY_DIR/template.json" ]]; then
echo "::error::Template not found: $DEPLOY_DIR/template.json"
exit 1
fi

if [[ -f "$DEPLOY_DIR/parameters.json" ]]; then
LOCATION=$(jq -r '.parameters.location.value // "eastus"' "$DEPLOY_DIR/parameters.json")
else
LOCATION="eastus"
fi

echo "location=$LOCATION" >> "$GITHUB_OUTPUT"
echo "deploy_dir=$DEPLOY_DIR" >> "$GITHUB_OUTPUT"

- name: Enforce required tags
id: tags
run: |
TEMPLATE="${{ steps.params.outputs.deploy_dir }}/template.json"
REQUIRED_TAGS=("Environment" "Project" "ManagedBy" "CreatedDate")
MISSING_TAGS=""

HAS_TAG_VAR=$(jq 'has("variables") and (.variables | has("tags"))' "$TEMPLATE")
RESOURCES_WITHOUT_TAGS=$(jq -r '
[.resources[] |
select(.type == "Microsoft.Resources/resourceGroups") |
select(.tags == null or .tags == "") |
.name
] | join(", ")
' "$TEMPLATE")

if [[ -n "$RESOURCES_WITHOUT_TAGS" ]]; then
MISSING_TAGS="Resource groups missing tags: $RESOURCES_WITHOUT_TAGS"
fi

if [[ "$HAS_TAG_VAR" == "true" ]]; then
for TAG in "${REQUIRED_TAGS[@]}"; do
TAG_EXISTS=$(jq --arg t "$TAG" '.variables.tags | has($t)' "$TEMPLATE")
if [[ "$TAG_EXISTS" != "true" ]]; then
MISSING_TAGS="${MISSING_TAGS}Missing required tag: $TAG\n"
fi
done
fi

if [[ -n "$MISSING_TAGS" ]]; then
echo "tag_status=failed" >> "$GITHUB_OUTPUT"
echo "tag_details<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$MISSING_TAGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "::warning::Tag enforcement: missing required tags"
else
echo "tag_status=passed" >> "$GITHUB_OUTPUT"
echo "All required tags present: ${REQUIRED_TAGS[*]}"
fi

- name: Estimate deployment cost
id: cost
env:
# location comes from parameters.json (attacker-controllable) โ€” route it
# through env to prevent run-script injection.
LOCATION: ${{ steps.params.outputs.location }}
run: |
TEMPLATE="${{ steps.params.outputs.deploy_dir }}/template.json"
REGION="$LOCATION"
COST_TABLE="| Resource Type | SKU | Est. Monthly |\n|---|---|---|\n"
TOTAL=0
COST_NOTES=""

COST_FILE="${{ steps.params.outputs.deploy_dir }}/cost-estimate.json"
if [[ -f "$COST_FILE" ]]; then
TOTAL=$(jq -r '.monthlyTotal // 0' "$COST_FILE")
COST_TABLE=$(jq -r '.resources[] | "| \(.type) | \(.sku // "-") | $\(.monthlyEstimate) |"' "$COST_FILE" | sort)
COST_TABLE="| Resource Type | SKU | Est. Monthly |\n|---|---|---|\n${COST_TABLE}"
else
VM_SKUS=$(jq -r '
[.. | objects | select(.type? == "Microsoft.Compute/virtualMachines") | .properties.hardwareProfile.vmSize // empty] | unique | .[]
' "$TEMPLATE" 2>/dev/null || true)

ASP_SKUS=$(jq -r '
[.. | objects | select(.type? == "Microsoft.Web/serverfarms") | .sku.name // empty] | unique | .[]
' "$TEMPLATE" 2>/dev/null || true)

for SKU in $VM_SKUS; do
PRICE=$(curl -sf "https://prices.azure.com/api/retail/prices?\$filter=serviceName%20eq%20%27Virtual%20Machines%27%20and%20armRegionName%20eq%20%27${REGION}%27%20and%20armSkuName%20eq%20%27${SKU}%27%20and%20priceType%20eq%20%27Consumption%27" \
| jq '[.Items[] | select(.isPrimaryMeterRegion == true and (.productName | test("Windows") | not))] | .[0].retailPrice // 0' 2>/dev/null || echo 0)
MONTHLY=$(echo "$PRICE * 730" | bc -l 2>/dev/null | xargs printf "%.2f" 2>/dev/null || echo "0.00")
COST_TABLE="${COST_TABLE}| Virtual Machine | ${SKU} | \$${MONTHLY} |\n"
TOTAL=$(echo "$TOTAL + $MONTHLY" | bc -l 2>/dev/null || echo "$TOTAL")
done

for SKU in $ASP_SKUS; do
PRICE=$(curl -sf "https://prices.azure.com/api/retail/prices?\$filter=serviceName%20eq%20%27Azure%20App%20Service%27%20and%20armRegionName%20eq%20%27${REGION}%27%20and%20armSkuName%20eq%20%27${SKU}%27%20and%20priceType%20eq%20%27Consumption%27" \
| jq '[.Items[] | select(.isPrimaryMeterRegion == true)] | .[0].retailPrice // 0' 2>/dev/null || echo 0)
MONTHLY=$(echo "$PRICE * 730" | bc -l 2>/dev/null | xargs printf "%.2f" 2>/dev/null || echo "0.00")
COST_TABLE="${COST_TABLE}| App Service Plan | ${SKU} | \$${MONTHLY} |\n"
TOTAL=$(echo "$TOTAL + $MONTHLY" | bc -l 2>/dev/null || echo "$TOTAL")
done

HAS_FUNCTIONS=$(jq '[.. | objects | select(.type? == "Microsoft.Web/sites" and (.kind? // "" | test("functionapp")))] | length' "$TEMPLATE" 2>/dev/null || echo 0)
if [[ "$HAS_FUNCTIONS" -gt 0 ]]; then
COST_TABLE="${COST_TABLE}| Function App (Consumption) | - | \$0.00* |\n"
COST_NOTES="*Function Apps on Consumption plan: first 1M executions + 400K GB-s free/month"
fi
fi

TOTAL_FMT=$(printf "%.2f" "$TOTAL" 2>/dev/null || echo "0.00")
echo "cost_total=$TOTAL_FMT" >> "$GITHUB_OUTPUT"
echo "cost_table<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$COST_TABLE" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "cost_notes<<EOF" >> "$GITHUB_OUTPUT"
echo "$COST_NOTES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

- name: Read architecture diagram
id: architecture
run: |
ARCH_FILE="${{ steps.params.outputs.deploy_dir }}/architecture.md"
if [[ -f "$ARCH_FILE" ]]; then
echo "has_architecture=true" >> "$GITHUB_OUTPUT"
{
echo "architecture_content<<EOF"
cat "$ARCH_FILE"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "has_architecture=false" >> "$GITHUB_OUTPUT"
fi

- name: Stage template for security scan
id: scan_stage
run: |
# WORKAROUND: Microsoft Defender for DevOps' templateanalyzer tool always
# runs `analyze-directory $GITHUB_WORKSPACE` and ignores GDN_TEMPLATEANALYZER_INPUT.
# Template Analyzer's file walker uses .NET EnumerationOptions which default to
# AttributesToSkip=Hidden|System. On Linux, .NET treats any path starting with
# "." as Hidden โ€” so .azure/deployments/<id>/template.json is silently skipped
# and the scanner reports "Analyzed 0 files in the directory specified."
# See: https://github.com/Azure/template-analyzer/blob/main/src/Analyzer.Utilities/TemplateDiscovery.cs
#
# Workaround: copy the template + parameters to a non-dotted directory at the
# workspace root so the walker discovers them.
STAGE_DIR="templateanalyzer-scan/$DEPLOYMENT_ID"
mkdir -p "$STAGE_DIR"
cp "${{ steps.params.outputs.deploy_dir }}/template.json" "$STAGE_DIR/template.json"
if [[ -f "${{ steps.params.outputs.deploy_dir }}/parameters.json" ]]; then
cp "${{ steps.params.outputs.deploy_dir }}/parameters.json" "$STAGE_DIR/template.parameters.json"
fi
echo "stage_dir=$STAGE_DIR" >> "$GITHUB_OUTPUT"
ls -la "$STAGE_DIR"

- name: Run Microsoft Defender for DevOps template analyzer
id: security_scan
continue-on-error: true
uses: microsoft/security-devops-action@v1
with:
tools: templateanalyzer

- name: Cleanup staged template
if: always()
run: rm -rf templateanalyzer-scan

- name: Upload SARIF results (non-blocking)
id: sarif_upload
if: always() && steps.security_scan.outputs.sarifFile != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.security_scan.outputs.sarifFile }}
category: templateanalyzer

- name: Parse security scan results
id: scan_results
if: always()
run: |
SARIF_FILE="${{ steps.security_scan.outputs.sarifFile }}"
if [[ -f "$SARIF_FILE" ]]; then
ERRORS=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
WARNINGS=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
NOTES=$(jq '[.runs[].results[] | select(.level == "note" or .level == "none")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)

echo "scan_errors=$ERRORS" >> "$GITHUB_OUTPUT"
echo "scan_warnings=$WARNINGS" >> "$GITHUB_OUTPUT"
echo "scan_notes=$NOTES" >> "$GITHUB_OUTPUT"

if [[ "$ERRORS" -gt 0 ]]; then
echo "scan_status=failed" >> "$GITHUB_OUTPUT"
else
echo "scan_status=passed" >> "$GITHUB_OUTPUT"
fi

FINDINGS=$(jq -r '.runs[].results[] | "- **\(.level | ascii_upcase):** \(.message.text) (\(.ruleId))"' "$SARIF_FILE" 2>/dev/null || echo "")
echo "scan_findings<<EOF" >> "$GITHUB_OUTPUT"
echo "$FINDINGS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "scan_status=skipped" >> "$GITHUB_OUTPUT"
echo "scan_errors=0" >> "$GITHUB_OUTPUT"
echo "scan_warnings=0" >> "$GITHUB_OUTPUT"
echo "scan_notes=0" >> "$GITHUB_OUTPUT"
echo "scan_findings=" >> "$GITHUB_OUTPUT"
fi

- name: Build local summary artifact
if: always()
env:
DEPLOYMENT_ID: ${{ matrix.deployment_id }}
TAG_STATUS: ${{ steps.tags.outputs.tag_status }}
TAG_DETAILS: ${{ steps.tags.outputs.tag_details }}
COST_TOTAL: ${{ steps.cost.outputs.cost_total }}
COST_TABLE: ${{ steps.cost.outputs.cost_table }}
COST_NOTES: ${{ steps.cost.outputs.cost_notes }}
HAS_ARCHITECTURE: ${{ steps.architecture.outputs.has_architecture }}
ARCHITECTURE_CONTENT: ${{ steps.architecture.outputs.architecture_content }}
SCAN_STATUS: ${{ steps.scan_results.outputs.scan_status }}
SCAN_ERRORS: ${{ steps.scan_results.outputs.scan_errors }}
SCAN_WARNINGS: ${{ steps.scan_results.outputs.scan_warnings }}
SCAN_NOTES: ${{ steps.scan_results.outputs.scan_notes }}
SCAN_FINDINGS: ${{ steps.scan_results.outputs.scan_findings }}
SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }}
SECURITY_SCAN_OUTCOME: ${{ steps.security_scan.outcome }}
run: |
mkdir -p .git-ape-plan
jq -n \
--arg deploymentId "$DEPLOYMENT_ID" \
--arg tagStatus "$TAG_STATUS" \
--arg tagDetails "$TAG_DETAILS" \
--arg costTotal "$COST_TOTAL" \
--arg costTable "$COST_TABLE" \
--arg costNotes "$COST_NOTES" \
--arg hasArchitecture "$HAS_ARCHITECTURE" \
--arg architectureContent "$ARCHITECTURE_CONTENT" \
--arg scanStatus "$SCAN_STATUS" \
--arg scanErrors "$SCAN_ERRORS" \
--arg scanWarnings "$SCAN_WARNINGS" \
--arg scanNotes "$SCAN_NOTES" \
--arg scanFindings "$SCAN_FINDINGS" \
--arg sarifUploadOutcome "$SARIF_UPLOAD_OUTCOME" \
--arg securityScanOutcome "$SECURITY_SCAN_OUTCOME" \
'{
deploymentId: $deploymentId,
tagStatus: $tagStatus,
tagDetails: $tagDetails,
costTotal: $costTotal,
costTable: $costTable,
costNotes: $costNotes,
hasArchitecture: $hasArchitecture,
architectureContent: $architectureContent,
scanStatus: $scanStatus,
scanErrors: $scanErrors,
scanWarnings: $scanWarnings,
scanNotes: $scanNotes,
scanFindings: $scanFindings,
sarifUploadOutcome: $sarifUploadOutcome,
securityScanOutcome: $securityScanOutcome
}' > ".git-ape-plan/plan-local-${DEPLOYMENT_ID}.json"

- name: Upload local summary artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: plan-local-${{ matrix.deployment_id }}
path: .git-ape-plan/plan-local-${{ matrix.deployment_id }}.json
if-no-files-found: error
retention-days: 1

plan-azure:
name: "Plan Azure: ${{ matrix.deployment_id }}"
needs: detect-deployments
if: needs.detect-deployments.outputs.has_deployments == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
deployment_id: ${{ fromJson(needs.detect-deployments.outputs.deployment_ids) }}
fail-fast: false
# See plan-local: route the attacker-controllable matrix value through env.
env:
DEPLOYMENT_ID: ${{ matrix.deployment_id }}

steps:
- uses: actions/checkout@v6

- name: Read deployment parameters
id: params
run: |
DEPLOY_DIR=".azure/deployments/$DEPLOYMENT_ID"

if [[ ! -f "$DEPLOY_DIR/template.json" ]]; then
echo "::error::Template not found: $DEPLOY_DIR/template.json"
exit 1
fi

if [[ -f "$DEPLOY_DIR/parameters.json" ]]; then
LOCATION=$(jq -r '.parameters.location.value // "eastus"' "$DEPLOY_DIR/parameters.json")
else
LOCATION="eastus"
fi

echo "location=$LOCATION" >> "$GITHUB_OUTPUT"
echo "deploy_dir=$DEPLOY_DIR" >> "$GITHUB_OUTPUT"

- name: Azure Login (OIDC)
id: azure_login
continue-on-error: 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: Validate template (stack)
id: validate
if: steps.azure_login.outcome == 'success'
env:
# location comes from parameters.json (attacker-controllable) โ€” route it
# through env to prevent run-script injection.
LOCATION: ${{ steps.params.outputs.location }}
run: |
echo "### Validating deployment stack..."

# az stack sub validate mirrors az deployment sub validate but also
# verifies stack-specific settings (action-on-unmanage, deny settings).
RESULT=$(az stack sub validate \
--name "$DEPLOYMENT_ID" \
--location "$LOCATION" \
--template-file "${{ steps.params.outputs.deploy_dir }}/template.json" \
--parameters @"${{ steps.params.outputs.deploy_dir }}/parameters.json" \
--action-on-unmanage deleteAll \
--deny-settings-mode none \
--output json 2>&1) || true

# Guard against non-JSON output (e.g. auth/CLI errors) โ€” jq exits non-zero
# on invalid input which would crash the script under bash -e.
ERROR=$(echo "$RESULT" | jq -r '.error // empty' 2>/dev/null || echo "")

if [[ -n "$ERROR" && "$ERROR" != "null" ]]; then
echo "validation_status=failed" >> "$GITHUB_OUTPUT"
echo "validation_error<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESULT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
elif echo "$RESULT" | jq -e . >/dev/null 2>&1; then
echo "validation_status=passed" >> "$GITHUB_OUTPUT"
else
# az returned non-JSON (e.g. a plain-text error or auth failure)
echo "validation_status=failed" >> "$GITHUB_OUTPUT"
echo "validation_error<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESULT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
fi

- name: Run what-if analysis
id: whatif
if: steps.azure_login.outcome == 'success'
env:
# location comes from parameters.json (attacker-controllable) โ€” route it
# through env to prevent run-script injection.
LOCATION: ${{ steps.params.outputs.location }}
run: |
# NOTE: Deployment Stacks don't yet support what-if
# (see https://learn.microsoft.com/azure/azure-resource-manager/bicep/deployment-stacks#known-issues).
# We fall back to `az deployment sub what-if` against the underlying
# ARM template โ€” this accurately previews resource changes even though
# it doesn't model the stack wrapper itself.
#
# Run unconditionally on login success: validation and what-if catch
# different classes of issues (schema vs. preflight/runtime), so even
# if validation failed, what-if may surface additional context.
set +e
WHATIF_OUTPUT=$(az deployment sub what-if \
--location "$LOCATION" \
--template-file "${{ steps.params.outputs.deploy_dir }}/template.json" \
--parameters @"${{ steps.params.outputs.deploy_dir }}/parameters.json" \
--no-prompt 2>&1)
WHATIF_EXIT=$?
set -e

if [[ $WHATIF_EXIT -eq 0 ]]; then
echo "whatif_status=passed" >> "$GITHUB_OUTPUT"
else
echo "whatif_status=failed" >> "$GITHUB_OUTPUT"
fi
echo "whatif_result<<EOF" >> "$GITHUB_OUTPUT"
echo "$WHATIF_OUTPUT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

- name: Build Azure summary artifact
if: always()
env:
DEPLOYMENT_ID: ${{ matrix.deployment_id }}
AZURE_LOGIN_OUTCOME: ${{ steps.azure_login.outcome }}
VALIDATION_STATUS: ${{ steps.validate.outputs.validation_status }}
VALIDATION_ERROR: ${{ steps.validate.outputs.validation_error }}
WHATIF_STATUS: ${{ steps.whatif.outputs.whatif_status }}
WHATIF_RESULT: ${{ steps.whatif.outputs.whatif_result }}
run: |
mkdir -p .git-ape-plan
FINAL_VALIDATION_STATUS="$VALIDATION_STATUS"
if [[ -z "$FINAL_VALIDATION_STATUS" ]]; then
if [[ "$AZURE_LOGIN_OUTCOME" == "failure" ]]; then
FINAL_VALIDATION_STATUS="login_failed"
else
FINAL_VALIDATION_STATUS="skipped"
fi
fi

FINAL_WHATIF_STATUS="$WHATIF_STATUS"
if [[ -z "$FINAL_WHATIF_STATUS" ]]; then
if [[ "$AZURE_LOGIN_OUTCOME" == "failure" ]]; then
FINAL_WHATIF_STATUS="login_failed"
else
FINAL_WHATIF_STATUS="skipped"
fi
fi

jq -n \
--arg deploymentId "$DEPLOYMENT_ID" \
--arg azureLoginOutcome "$AZURE_LOGIN_OUTCOME" \
--arg validationStatus "$FINAL_VALIDATION_STATUS" \
--arg validationError "$VALIDATION_ERROR" \
--arg whatifStatus "$FINAL_WHATIF_STATUS" \
--arg whatifResult "$WHATIF_RESULT" \
'{
deploymentId: $deploymentId,
azureLoginOutcome: $azureLoginOutcome,
validationStatus: $validationStatus,
validationError: $validationError,
whatifStatus: $whatifStatus,
whatifResult: $whatifResult
}' > ".git-ape-plan/plan-azure-${DEPLOYMENT_ID}.json"

- name: Upload Azure summary artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: plan-azure-${{ matrix.deployment_id }}
path: .git-ape-plan/plan-azure-${{ matrix.deployment_id }}.json
if-no-files-found: error
retention-days: 1

- name: What-if gate
# Runs AFTER the artifact upload so the Plan Comment job still posts the
# full plan (validation + scan + what-if details) to the PR. This step
# only fails the Plan Azure job to block merge/deploy when what-if
# cannot produce a valid deployment preview โ€” a failed what-if means the
# template wouldn't deploy successfully even if everything else is green.
if: steps.whatif.outputs.whatif_status == 'failed'
run: |
echo "::error::What-if analysis failed โ€” deployment would not succeed. See the PR comment for the full output."
exit 1

plan-comment:
name: "Plan Comment: ${{ matrix.deployment_id }}"
needs: [detect-deployments, plan-local, plan-azure]
if: always() && needs.detect-deployments.outputs.has_deployments == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
deployment_id: ${{ fromJson(needs.detect-deployments.outputs.deployment_ids) }}
fail-fast: false
# See plan-local: route the attacker-controllable matrix value through env so
# the github-script step reads process.env.DEPLOYMENT_ID, not an inlined ${{ }}.
env:
DEPLOYMENT_ID: ${{ matrix.deployment_id }}

steps:
- name: Download local summary artifact
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: plan-local-${{ matrix.deployment_id }}
path: .git-ape-plan/local

- name: Download Azure summary artifact
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: plan-azure-${{ matrix.deployment_id }}
path: .git-ape-plan/azure

- name: Post plan as PR comment
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const deploymentId = process.env.DEPLOYMENT_ID;

function loadSummary(kind) {
const path = `.git-ape-plan/${kind}/plan-${kind}-${deploymentId}.json`;
if (!fs.existsSync(path)) {
return null;
}
return JSON.parse(fs.readFileSync(path, 'utf8'));
}

// Fetch templateanalyzer Code Scanning alerts for THIS PR so we can render
// each finding as a clickable link to its alert page (Security tab) and to
// the rule documentation. Falls back gracefully if the API call fails or
// returns no alerts (e.g. SARIF upload was skipped or still processing).
async function fetchTemplateAnalyzerAlerts() {
try {
const ref = `refs/pull/${context.issue.number}/merge`;
const { data: alerts } = await github.rest.codeScanning.listAlertsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
tool_name: 'templateanalyzer',
per_page: 100,
});
return alerts;
} catch (err) {
core.warning(`Could not fetch templateanalyzer alerts: ${err.message}`);
return [];
}
}

function renderAlertsTable(alerts) {
if (!alerts || alerts.length === 0) return '';
const sevIcon = (s) => ({ error: '๐Ÿ”ด', warning: '๐ŸŸก', note: '๐Ÿ”ต', none: 'โšช' }[s] || 'โšช');
let table = '| Sev | Rule | Line | Description |\n';
table += '|---|---|---|---|\n';
for (const a of alerts) {
const sev = a.rule?.severity || 'none';
const ruleId = a.rule?.id || '?';
const ruleName = a.rule?.name || '';
const helpUri = a.rule?.help_uri || '';
const ruleLabel = helpUri
? `[\`${ruleId}\`](${helpUri}) ${ruleName}`
: `\`${ruleId}\` ${ruleName}`;
const line = a.most_recent_instance?.location?.start_line || '?';
const desc = (a.rule?.description || '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
const alertLink = `[#${a.number}](${a.html_url})`;
table += `| ${sevIcon(sev)} | ${alertLink} ยท ${ruleLabel} | ${line} | ${desc} |\n`;
}
return table;
}

const alerts = await fetchTemplateAnalyzerAlerts();
const local = loadSummary('local') || {};
const azure = loadSummary('azure') || {};

const validationStatus = azure.validationStatus || 'skipped';
const validationError = azure.validationError || '';
const whatifStatus = azure.whatifStatus || 'skipped';
const whatifResult = azure.whatifResult || '';
const azureLoginOutcome = azure.azureLoginOutcome || '';
const scanStatus = local.scanStatus || 'skipped';
const scanErrors = local.scanErrors || '0';
const scanWarnings = local.scanWarnings || '0';
const scanNotes = local.scanNotes || '0';
const scanFindings = local.scanFindings || '';
const sarifUploadOutcome = local.sarifUploadOutcome || '';
const securityScanOutcome = local.securityScanOutcome || '';
const tagStatus = local.tagStatus || '';
const tagDetails = local.tagDetails || '';
const costTotal = local.costTotal || '';
const costTable = local.costTable || '';
const costNotes = local.costNotes || '';
const hasArchitecture = local.hasArchitecture === 'true';
const architectureContent = local.architectureContent || '';

let comment = `## Git-Ape Plan: \`${deploymentId}\`\n\n`;

if (validationStatus === 'passed') {
comment += `### โœ… Template Validation: Passed\n\n`;
} else if (validationStatus === 'failed') {
comment += `### โŒ Template Validation: Failed\n\n`;
comment += `\`\`\`\n${validationError}\n\`\`\`\n\n`;
comment += `> Fix the template and push again to re-run validation.\n\n`;
} else if (validationStatus === 'login_failed') {
comment += `### โŒ Azure Login: Failed\n\n`;
comment += `> OIDC login failed, so Azure validation and what-if did not run.\n\n`;
} else {
comment += `### โš ๏ธ Template Validation: Skipped\n\n`;
}

if (tagStatus === 'passed') {
comment += `### โœ… Tag Enforcement: Passed\n\n`;
} else if (tagStatus === 'failed') {
comment += `### โš ๏ธ Tag Enforcement: Issues Found\n\n`;
comment += `${tagDetails}\n\n`;
}

// Security scan runs locally on the template file and is independent of
// Azure validation. Always render the section so reviewers see the result
// even when validation fails.
if (securityScanOutcome === 'failure' && scanStatus === 'skipped') {
comment += `### โš ๏ธ Security Scan: Tool Execution Failed\n\n`;
} else if (scanStatus === 'passed') {
comment += `### โœ… Security Scan: Passed`;
if (parseInt(scanWarnings) > 0 || parseInt(scanNotes) > 0) {
comment += ` (${scanWarnings} warning(s), ${scanNotes} note(s))`;
}
comment += `\n\n`;
} else if (scanStatus === 'failed') {
comment += `### โŒ Security Scan: Failed (${scanErrors} error(s), ${scanWarnings} warning(s))\n\n`;
} else {
comment += `### โš ๏ธ Security Scan: Skipped\n\n`;
}

// Prefer the live Code Scanning alerts table (rich links into the Security
// tab + rule docs). Fall back to the inline SARIF text findings when the
// alerts API hasn't surfaced them yet (e.g. SARIF still processing).
const alertsTable = renderAlertsTable(alerts);
const codeScanningFilterUrl =
`https://github.com/${context.repo.owner}/${context.repo.repo}` +
`/security/code-scanning?query=pr%3A${context.issue.number}+tool%3Atemplateanalyzer`;

if (alertsTable) {
comment += `<details open>\n<summary>${alerts.length} finding(s) โ€” Microsoft Defender for DevOps ยท Template Analyzer</summary>\n\n${alertsTable}\n\n</details>\n\n`;
comment += `> ๐Ÿ”— **[View all ${alerts.length} alerts in the Security tab โ†’](${codeScanningFilterUrl})**\n\n`;
} else if (scanFindings) {
comment += `<details open>\n<summary>Security findings (Microsoft Defender for DevOps ยท Template Analyzer)</summary>\n\n${scanFindings}\n\n</details>\n\n`;
comment += `> ๐Ÿ”— **[View in Security tab โ†’](${codeScanningFilterUrl})** (alerts may take a moment to appear after upload)\n\n`;
}
if (sarifUploadOutcome === 'failure') {
comment += `> SARIF upload to GitHub code scanning failed, but this does not block plan generation.\n\n`;
}

if (costTotal && validationStatus === 'passed') {
comment += `### ๐Ÿ’ฐ Estimated Monthly Cost: $${costTotal}\n\n`;
comment += `${costTable}\n`;
if (costNotes) {
comment += `\n> ${costNotes}\n\n`;
}
comment += `*Retail pay-as-you-go pricing from Azure Retail Prices API*\n\n`;
}

if (hasArchitecture) {
comment += `### Architecture\n\n${architectureContent}\n\n`;
}

// What-if rendering is driven solely by whatifStatus โ€” validation and
// what-if are decoupled gates that catch different classes of issues.
if (whatifStatus === 'passed' && whatifResult) {
comment += `### What-If Analysis\n\n`;
comment += `\`\`\`\n${whatifResult}\n\`\`\`\n\n`;
} else if (whatifStatus === 'failed') {
comment += `### โŒ What-If Analysis: Failed\n\n`;
comment += `\`\`\`\n${whatifResult}\n\`\`\`\n\n`;
} else if (whatifStatus === 'login_failed') {
comment += `### โš ๏ธ What-If Analysis: Skipped\n\n`;
comment += `> Skipped because Azure OIDC login failed.\n\n`;
} else {
comment += `### โš ๏ธ What-If Analysis: Skipped\n\n`;
comment += `> What-if did not run. See validation/login status above.\n\n`;
}

if (validationStatus === 'passed') {
comment += `---\n`;
comment += `### Next Steps\n\n`;
comment += `1. Review the plan above\n`;
comment += `2. Approve and merge this PR to trigger deployment\n`;
}

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const marker = `<!-- git-ape-plan:${deploymentId} -->`;
comment = marker + '\n' + comment;
const existing = comments.find(c => c.body.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: comment,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment,
});
}