name: "Git-Ape: Deploy"
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
branches: [main]
paths:
- ".azure/deployments/**/template.json"
- ".azure/deployments/**/parameters.json"
issue_comment:
types: [created]
permissions:
id-token: write
contents: write
pull-requests: write
issues: write
security-events: write
actions: read
concurrency:
group: git-ape-deploy-${{ github.event_name == 'push' && github.sha || github.event.comment.id }}
cancel-in-progress: false
jobs:
check-comment-trigger:
name: Check /deploy trigger
if: github.event_name == 'issue_comment'
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.check.outputs.should_deploy }}
pr_ref: ${{ steps.check.outputs.pr_ref }}
steps:
- name: Check comment and PR status
id: check
uses: actions/github-script@v8
with:
script: |
const comment = context.payload.comment.body.trim();
if (!comment.startsWith('/deploy')) {
core.setOutput('should_deploy', 'false');
return;
}
// Must be on a PR (not a regular issue)
if (!context.payload.issue.pull_request) {
core.setOutput('should_deploy', 'false');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '❌ `/deploy` can only be used on pull requests.',
});
return;
}
// Get PR details
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
// Check PR is approved
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const approved = reviews.some(r => r.state === 'APPROVED');
if (!approved) {
core.setOutput('should_deploy', 'false');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '❌ PR must be **approved** before deploying. Get a review approval first.',
});
return;
}
core.setOutput('should_deploy', 'true');
core.setOutput('pr_ref', pr.head.ref);
// React to the comment
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
detect-deployments:
name: Detect deployments to execute
needs: [check-comment-trigger]
if: |
always() &&
(github.event_name == 'push' ||
(github.event_name == 'issue_comment' && needs.check-comment-trigger.outputs.should_deploy == 'true'))
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:
ref: ${{ needs.check-comment-trigger.outputs.pr_ref || github.ref }}
fetch-depth: 0
- name: Find deployment directories
id: find
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
# On merge: find deployments changed in the merge commit
CHANGED_FILES=$(git diff --name-only HEAD~1...HEAD -- '.azure/deployments/*/template.json' 2>/dev/null || true)
else
# On /deploy comment: find all deployments with template.json on the branch
CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- '.azure/deployments/*/template.json' 2>/dev/null || true)
fi
if [[ -z "$CHANGED_FILES" ]]; then
echo "has_deployments=false" >> "$GITHUB_OUTPUT"
echo "deployment_ids=[]" >> "$GITHUB_OUTPUT"
echo "No deployments found"
exit 0
fi
DEPLOYMENT_IDS=$(echo "$CHANGED_FILES" | sed 's|.azure/deployments/\([^/]*\)/.*|\1|' | sort -u | jq -R -s -c 'split("\n") | map(select(. != ""))')
echo "has_deployments=true" >> "$GITHUB_OUTPUT"
echo "deployment_ids=$DEPLOYMENT_IDS" >> "$GITHUB_OUTPUT"
echo "Deployments to execute: $DEPLOYMENT_IDS"
deploy:
name: "Deploy: ${{ matrix.deployment_id }}"
needs: [detect-deployments, check-comment-trigger]
if: |
always() &&
needs.detect-deployments.outputs.has_deployments == 'true'
runs-on: ubuntu-latest
environment: azure-deploy
strategy:
matrix:
deployment_id: ${{ fromJson(needs.detect-deployments.outputs.deployment_ids) }}
max-parallel: 1
fail-fast: false
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.check-comment-trigger.outputs.pr_ref || github.ref }}
- name: Read deployment parameters
id: params
run: |
DEPLOY_DIR=".azure/deployments/${{ matrix.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")
PROJECT=$(jq -r '.parameters.project.value // .parameters.projectName.value // "unknown"' "$DEPLOY_DIR/parameters.json")
ENVIRONMENT=$(jq -r '.parameters.environment.value // "dev"' "$DEPLOY_DIR/parameters.json")
else
LOCATION="eastus"
PROJECT="unknown"
ENVIRONMENT="dev"
fi
echo "location=$LOCATION" >> "$GITHUB_OUTPUT"
echo "project=$PROJECT" >> "$GITHUB_OUTPUT"
echo "environment=$ENVIRONMENT" >> "$GITHUB_OUTPUT"
echo "deploy_dir=$DEPLOY_DIR" >> "$GITHUB_OUTPUT"
- name: Azure Login (OIDC)
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 before deploy (stack)
run: |
az stack sub validate \
--name "${{ matrix.deployment_id }}" \
--location "${{ steps.params.outputs.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
- name: Run Microsoft Defender for DevOps template analyzer
id: security_scan
continue-on-error: true
uses: microsoft/security-devops-action@v1
with:
tools: templateanalyzer
env:
GDN_TEMPLATEANALYZER_INPUT: ${{ steps.params.outputs.deploy_dir }}/template.json
- name: Upload SARIF results
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: Check security scan results
id: scan_gate
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)
if [[ "$ERRORS" -gt 0 ]]; then
echo "::error::Template analyzer found $ERRORS security error(s). Deployment blocked."
jq -r '.runs[].results[] | select(.level == "error") | " ERROR: \(.message.text) (\(.ruleId))"' "$SARIF_FILE"
exit 1
fi
echo "Security scan passed — no errors found"
fi
- name: Deploy to Azure (Deployment Stack)
id: deploy
run: |
STACK_NAME="${{ matrix.deployment_id }}"
echo "🚀 Starting stack deployment: $STACK_NAME"
START_TIME=$(date +%s)
DEPLOY_OUTPUT=$(az stack sub create \
--name "$STACK_NAME" \
--location "${{ steps.params.outputs.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 \
--description "Git-Ape deployment $STACK_NAME" \
--tags "managedBy=git-ape" "deploymentId=$STACK_NAME" \
--yes \
--output json 2>&1)
EXIT_CODE=$?
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "deploy_duration=${DURATION}s" >> "$GITHUB_OUTPUT"
if [[ $EXIT_CODE -ne 0 ]]; then
echo "deploy_status=failed" >> "$GITHUB_OUTPUT"
echo "deploy_error<<EOF" >> "$GITHUB_OUTPUT"
echo "$DEPLOY_OUTPUT" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo ""
echo "=========================================="
echo "❌ STACK DEPLOYMENT FAILED"
echo "=========================================="
echo "$DEPLOY_OUTPUT"
echo "=========================================="
echo "::error::Stack deployment failed — see output above for details"
exit 1
fi
echo "deploy_status=succeeded" >> "$GITHUB_OUTPUT"
STACK_ID=$(echo "$DEPLOY_OUTPUT" | jq -r '.id // empty')
echo "stack_id=$STACK_ID" >> "$GITHUB_OUTPUT"
OUTPUTS=$(echo "$DEPLOY_OUTPUT" | jq -r '.outputs // .properties.outputs // {}')
echo "deploy_outputs<<EOF" >> "$GITHUB_OUTPUT"
echo "$OUTPUTS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
RG_NAME=$(echo "$OUTPUTS" | jq -r '.resourceGroupName.value // empty')
echo "resource_group=$RG_NAME" >> "$GITHUB_OUTPUT"
MANAGED=$(echo "$DEPLOY_OUTPUT" | jq -c '[(.resources // .properties.resources // [])[] | {id: .id, status: .status}]')
echo "managed_resources<<EOF" >> "$GITHUB_OUTPUT"
echo "$MANAGED" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "✅ Stack deployed in ${DURATION}s — stackId: $STACK_ID"
echo " Managed resources: $(echo "$MANAGED" | jq 'length')"
- name: Run integration tests
id: tests
if: steps.deploy.outputs.deploy_status == 'succeeded'
run: |
RG_NAME="${{ steps.deploy.outputs.resource_group }}"
if [[ -z "$RG_NAME" ]]; then
echo "⚠️ No resource group name in outputs, skipping integration tests"
echo "test_status=skipped" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Running integration tests for RG: $RG_NAME"
RESOURCES=$(az resource list --resource-group "$RG_NAME" \
--query "[].{name:name, type:type, provisioningState:provisioningState}" \
--output json 2>/dev/null || echo "[]")
echo "resources<<EOF" >> "$GITHUB_OUTPUT"
echo "$RESOURCES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
FAILED=$(echo "$RESOURCES" | jq '[.[] | select(.provisioningState != "Succeeded")] | length')
if [[ "$FAILED" -gt 0 ]]; then
echo "test_status=failed" >> "$GITHUB_OUTPUT"
echo "::warning::$FAILED resource(s) not in Succeeded state"
else
echo "test_status=passed" >> "$GITHUB_OUTPUT"
fi
ENDPOINTS=$(echo "$RESOURCES" | jq -r '.[] | select(.type == "Microsoft.App/containerApps" or .type == "Microsoft.Web/sites") | .name')
TEST_RESULTS=""
for NAME in $ENDPOINTS; do
RESOURCE_TYPE=$(echo "$RESOURCES" | jq -r ".[] | select(.name == \"$NAME\") | .type")
if [[ "$RESOURCE_TYPE" == "Microsoft.App/containerApps" ]]; then
FQDN=$(az containerapp show -n "$NAME" -g "$RG_NAME" --query "properties.configuration.ingress.fqdn" -o tsv 2>/dev/null || echo "")
else
FQDN=$(az webapp show -n "$NAME" -g "$RG_NAME" --query "defaultHostName" -o tsv 2>/dev/null || echo "")
fi
if [[ -n "$FQDN" ]]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://$FQDN" 2>/dev/null || echo "000")
TEST_RESULTS="${TEST_RESULTS}\n- ${NAME}: https://${FQDN} → HTTP ${HTTP_CODE}"
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 400 ]]; then
echo "✅ $NAME: HTTP $HTTP_CODE"
else
echo "⚠️ $NAME: HTTP $HTTP_CODE (may still be starting)"
fi
fi
done
echo "test_endpoints<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$TEST_RESULTS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Save deployment state
if: always()
run: |
DEPLOY_DIR="${{ steps.params.outputs.deploy_dir }}"
STATUS="${{ steps.deploy.outputs.deploy_status || 'failed' }}"
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STACK_ID='${{ steps.deploy.outputs.stack_id }}'
MANAGED='${{ steps.deploy.outputs.managed_resources }}'
MANAGED=${MANAGED:-[]}
jq -n \
--arg schemaVersion "1.0" \
--arg deploymentId "${{ matrix.deployment_id }}" \
--arg timestamp "$TIMESTAMP" \
--arg status "$STATUS" \
--arg duration "${{ steps.deploy.outputs.deploy_duration }}" \
--arg subscription "${{ vars.AZURE_SUBSCRIPTION_ID }}" \
--arg location "${{ steps.params.outputs.location }}" \
--arg project "${{ steps.params.outputs.project }}" \
--arg environment "${{ steps.params.outputs.environment }}" \
--arg resourceGroup "${{ steps.deploy.outputs.resource_group }}" \
--arg stackId "$STACK_ID" \
--argjson managedResources "$MANAGED" \
--arg triggeredBy "${{ github.actor }}" \
--arg triggerEvent "${{ github.event_name }}" \
--arg runId "${{ github.run_id }}" \
--arg runUrl "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
'{
schemaVersion: $schemaVersion,
deploymentId: $deploymentId,
timestamp: $timestamp,
status: $status,
duration: $duration,
subscription: $subscription,
location: $location,
project: $project,
environment: $environment,
resourceGroup: $resourceGroup,
stackId: (if $stackId == "" then null else $stackId end),
managedResources: $managedResources,
triggeredBy: $triggeredBy,
triggerEvent: $triggerEvent,
runId: $runId,
runUrl: $runUrl
}' > "$DEPLOY_DIR/state.json"
- name: Commit deployment state
if: always()
run: |
DEPLOY_DIR="${{ steps.params.outputs.deploy_dir }}"
STATUS="${{ steps.deploy.outputs.deploy_status }}"
STATUS=${STATUS:-failed}
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"
cp "$DEPLOY_DIR/state.json" /tmp/state.json 2>/dev/null || true
cp "$DEPLOY_DIR/metadata.json" /tmp/metadata.json 2>/dev/null || true
git fetch origin main
git checkout main
cp /tmp/state.json "$DEPLOY_DIR/state.json" 2>/dev/null || true
cp /tmp/metadata.json "$DEPLOY_DIR/metadata.json" 2>/dev/null || true
git add "$DEPLOY_DIR/state.json" "$DEPLOY_DIR/metadata.json"
git diff --cached --quiet || git commit -m "git-ape: update state for ${{ matrix.deployment_id }} [$STATUS]"
git push || echo "::warning::Could not push state update to main"
- name: Post deployment result
if: always() && github.event_name == 'issue_comment'
uses: actions/github-script@v8
with:
script: |
const deploymentId = '${{ matrix.deployment_id }}';
const status = '${{ steps.deploy.outputs.deploy_status }}' || 'failed';
const duration = '${{ steps.deploy.outputs.deploy_duration }}';
const outputs = `${{ steps.deploy.outputs.deploy_outputs }}`;
const resources = `${{ steps.tests.outputs.resources }}`;
const testEndpoints = `${{ steps.tests.outputs.test_endpoints }}`;
const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
let comment = `
if (status === 'succeeded') {
comment += `
comment += `- **Duration:** ${duration}\n`;
comment += `- **Workflow Run:** [View logs](${runUrl})\n\n`;
if (testEndpoints) {
comment += `
}
if (resources) {
try {
const parsed = JSON.parse(resources);
comment += `
comment += `| Name | Type | Status |\n|------|------|--------|\n`;
for (const r of parsed) {
const icon = r.provisioningState === 'Succeeded' ? '✅' : '⚠️';
comment += `| ${r.name} | ${r.type} | ${icon} ${r.provisioningState} |\n`;
}
comment += '\n';
} catch {}
}
} else {
comment += `
comment += `- **Workflow Run:** [View logs](${runUrl})\n\n`;
const error = `${{ steps.deploy.outputs.deploy_error }}`;
if (error) {
comment += `\`\`\`\n${error.substring(0, 2000)}\n\`\`\`\n\n`;
}
}
const marker = `<!-- git-ape-deploy:${deploymentId} -->`;
comment = marker + '\n' + comment;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment,
});
- 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
STATUS="${{ steps.deploy.outputs.deploy_status }}"
DEPLOY_ID="${{ matrix.deployment_id }}"
DURATION="${{ steps.deploy.outputs.deploy_duration }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [[ "$STATUS" == "succeeded" ]]; then
EMOJI="✅"
MSG="Deployment *$DEPLOY_ID* succeeded in $DURATION"
else
EMOJI="❌"
MSG="Deployment *$DEPLOY_ID* failed"
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 Deploy: $DEPLOY_ID*\\n\\n$MSG\\n\\nTriggered by: ${{ github.actor }}\\n<$RUN_URL|View logs>\"
}
}
]
}" || echo "::warning::Slack notification failed"