diff --git a/.github/actions/pr-comment/action.yaml b/.github/actions/pr-comment/action.yaml new file mode 100644 index 0000000..7ffbcc3 --- /dev/null +++ b/.github/actions/pr-comment/action.yaml @@ -0,0 +1,53 @@ +name: 'Create or Update PR Comment' +description: 'Creates or updates a PR comment with specified content' + +inputs: + body-includes: + description: 'Unique string to identify existing comments' + required: true + comment-body: + description: 'The comment body content' + required: true + issue-number: + description: 'PR number' + required: true + app-id: + description: 'GitHub App ID' + required: false + default: '2161597' + private-key: + description: 'GitHub App private key' + required: true + +outputs: + comment-id: + description: 'The ID of the created/updated comment' + value: ${{ steps.create-comment.outputs.comment-id }} + +runs: + using: 'composite' + steps: + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find-comment + with: + token: ${{ steps.app-token.outputs.token }} + issue-number: ${{ inputs.issue-number }} + comment-author: 'ainsley-dev-bot[bot]' + body-includes: ${{ inputs.body-includes }} + + - name: Create or update comment + uses: peter-evans/create-or-update-comment@v5 + id: create-comment + with: + token: ${{ steps.app-token.outputs.token }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ inputs.issue-number }} + body: ${{ inputs.comment-body }} + edit-mode: replace diff --git a/.github/actions/slack-notify/action.yaml b/.github/actions/slack-notify/action.yaml new file mode 100644 index 0000000..3ac15b8 --- /dev/null +++ b/.github/actions/slack-notify/action.yaml @@ -0,0 +1,222 @@ +name: 'Slack Notification' +description: 'Send rich notifications to Slack with Block Kit formatting' + +inputs: + slack_bot_token: + description: 'Slack bot token (xoxb-...)' + required: true + channel_id: + description: 'Slack channel ID to post to' + required: true + title: + description: 'Notification title (no emojis - added automatically based on status)' + required: true + message: + description: 'Main message body' + required: true + status: + description: 'Notification status: success, failure, warning, or info' + required: true + commit_sha: + description: 'Commit SHA for linking' + required: false + default: '' + buttons: + description: 'Optional JSON array of custom buttons: [{"text": "Button Text", "url": "https://..."}]' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Send Slack Notification + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_bot_token }} + CHANNEL_ID: ${{ inputs.channel_id }} + TITLE: ${{ inputs.title }} + MESSAGE: ${{ inputs.message }} + STATUS: ${{ inputs.status }} + COMMIT_SHA: ${{ inputs.commit_sha }} + BUTTONS: ${{ inputs.buttons }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + # Fail on errors, undefined vars, and fail if any part of a pipe fails + set -euo pipefail + + # Determine colour based on status + case "$STATUS" in + success) + COLOR="#36a64f" + ;; + failure) + COLOR="#ff0000" + ;; + warning) + COLOR="#ffaa00" + ;; + info) + COLOR="#2196F3" + ;; + *) + COLOR="#808080" + ;; + esac + + # Get current timestamp + TIMESTAMP=$(date +%s) + + # Convert \n escape sequences to actual newlines for proper Slack rendering + # This handles both literal \n from YAML and actual newlines from command output + MESSAGE_FORMATTED=$(printf '%b' "$MESSAGE") + + # Build Slack message with Block Kit using jq for proper JSON escaping + if [ -n "$COMMIT_SHA" ]; then + SHORT_SHA="${COMMIT_SHA:0:7}" + # Build commit link text with actual newline + COMMIT_LINK=$(printf '*Commit:*\n<%s/commit/%s|%s>' "$REPO_URL" "$COMMIT_SHA" "$SHORT_SHA") + FIELDS_JSON=$(jq -n \ + --arg status_text "$(printf '*Status:*\n%s' "${STATUS^}")" \ + --arg actor_text "$(printf '*Triggered By:*\n%s' "$ACTOR")" \ + --arg commit_text "$COMMIT_LINK" \ + '[ + {"type": "mrkdwn", "text": $status_text}, + {"type": "mrkdwn", "text": $actor_text}, + {"type": "mrkdwn", "text": $commit_text} + ]') + else + FIELDS_JSON=$(jq -n \ + --arg status_text "$(printf '*Status:*\n%s' "${STATUS^}")" \ + --arg actor_text "$(printf '*Triggered By:*\n%s' "$ACTOR")" \ + '[ + {"type": "mrkdwn", "text": $status_text}, + {"type": "mrkdwn", "text": $actor_text} + ]') + fi + + # Build custom buttons array from input + if [ -n "$BUTTONS" ] && [ "$BUTTONS" != "[]" ] && [ "$BUTTONS" != "" ]; then + # Parse custom buttons and convert to Slack button format + CUSTOM_BUTTONS=$(echo "$BUTTONS" | jq -c '[.[] | { + "type": "button", + "text": { + "type": "plain_text", + "text": .text + }, + "url": .url + }]') + else + CUSTOM_BUTTONS="[]" + fi + + # Build standard buttons (workflow and repository) + STANDARD_BUTTONS=$(jq -n \ + --arg workflow_url "$WORKFLOW_URL" \ + --arg repo_url "$REPO_URL" \ + '[ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Workflow" + }, + "url": $workflow_url + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View Repository" + }, + "url": $repo_url + } + ]') + + # Combine custom buttons with standard buttons (custom buttons first) + ALL_BUTTONS=$(jq -n \ + --argjson custom "$CUSTOM_BUTTONS" \ + --argjson standard "$STANDARD_BUTTONS" \ + '$custom + $standard') + + # Build the full payload with jq to ensure proper JSON escaping + PAYLOAD=$(jq -n \ + --arg channel "$CHANNEL_ID" \ + --arg color "$COLOR" \ + --arg title "$TITLE" \ + --argjson fields "$FIELDS_JSON" \ + --arg message "$MESSAGE_FORMATTED" \ + --argjson buttons "$ALL_BUTTONS" \ + --arg workflow "$GITHUB_WORKFLOW" \ + --arg timestamp "$TIMESTAMP" \ + '{ + "channel": $channel, + "text": "", + "attachments": [ + { + "color": $color, + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": $title + } + }, + { + "type": "section", + "fields": $fields + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": $message + } + }, + { + "type": "actions", + "elements": $buttons + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ("Workflow: " + $workflow + " | Time: ") + } + ] + } + ] + } + ] + }') + + # Send to Slack + RESPONSE=$(curl -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "${PAYLOAD}" \ + -w "\n%{http_code}" \ + -s) + + # Extract HTTP status code and response body + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$RESPONSE" | head -n-1) + + # Check if the request was successful + if [ "$HTTP_CODE" != "200" ]; then + echo "Error: Failed to send Slack notification (HTTP ${HTTP_CODE})" + echo "Response: ${RESPONSE_BODY}" + exit 1 + fi + + # Check if Slack API returned ok: true + if ! echo "$RESPONSE_BODY" | grep -q '"ok":true'; then + echo "Error: Slack API returned an error" + echo "Response: ${RESPONSE_BODY}" + exit 1 + fi + + echo "āœ“ Slack notification sent successfully" diff --git a/.github/workflows/helper-apply.yaml b/.github/workflows/helper-apply.yaml index db460fb..93a4e0c 100644 --- a/.github/workflows/helper-apply.yaml +++ b/.github/workflows/helper-apply.yaml @@ -165,54 +165,66 @@ jobs: echo "exitcode=1" >> $GITHUB_OUTPUT fi - - name: Post Apply Results + - name: Prepare Apply Comment Body + id: prepare-apply-comment if: always() && (steps.apply-with-plan.outcome != 'skipped' || steps.apply-fresh.outcome != 'skipped') - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const applyOutputPath = '${{ inputs.terraform-directory }}/apply-output.txt'; + working-directory: ${{ inputs.terraform-directory }} + run: | + # Build comment body + HEADER="## šŸš€ Terraform Apply Results" - let body = '## šŸš€ Terraform Apply Results\n\n'; + SUCCESS="${{ steps.apply.outputs.exitcode == '0' }}" - const success = '${{ steps.apply.outputs.exitcode }}' === '0'; + if [ "$SUCCESS" == "true" ]; then + STATUS="āœ… **Infrastructure changes applied successfully**" + else + STATUS="āŒ **Apply failed - please check logs**" + fi - if (success) { - body += 'āœ… **Infrastructure changes applied successfully**\n\n'; - } else { - body += 'āŒ **Apply failed - please check logs**\n\n'; - } + # Add apply output if available + APPLY_OUTPUT="" + if [ -f apply-output.txt ]; then + APPLY_OUTPUT="
+šŸ“„ View Apply Output - // Add apply output if available - if (fs.existsSync(applyOutputPath)) { - const applyOutput = fs.readFileSync(applyOutputPath, 'utf8'); - body += '
\nšŸ“„ View Apply Output\n\n'; - body += '```\n' + applyOutput + '\n```\n'; - body += '
\n'; - } +\`\`\` +$(cat apply-output.txt) +\`\`\` +
" + fi - body += `\nšŸ”— [View Workflow Run](${context.payload.repository.html_url}/actions/runs/${context.runId})`; - - // Try to find and comment on the merged PR - const prNumber = '${{ inputs.pr-number || steps.find-pr.outputs.pr-number }}'; - - if (prNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(prNumber), - body: body - }); - console.log(`Posted results to PR #${prNumber}`); - } catch (error) { - console.log(`Could not post to PR #${prNumber}: ${error.message}`); - } - } else { - console.log('No PR found to comment on'); - } + FOOTER="šŸ”— [View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + + # Combine all parts using heredoc + { + echo "COMMENT_BODY<> $GITHUB_OUTPUT + + - name: Post Apply Results to PR + if: always() && (steps.apply-with-plan.outcome != 'skipped' || steps.apply-fresh.outcome != 'skipped') && (inputs.pr-number != '' || steps.find-pr.outputs.pr-number != '') + uses: ./.github/actions/pr-comment + continue-on-error: true + with: + body-includes: 'Terraform Apply Results' + comment-body: ${{ steps.prepare-apply-comment.outputs.COMMENT_BODY }} + issue-number: ${{ inputs.pr-number || steps.find-pr.outputs.pr-number }} + private-key: ${{ secrets.ORG_GITHUB_APP_PRIVATE_KEY }} + + - name: Post Apply Results as Commit Comment + if: always() && (steps.apply-with-plan.outcome != 'skipped' || steps.apply-fresh.outcome != 'skipped') + uses: actions/github-script@v7 + continue-on-error: true + with: + script: | + const body = `${{ steps.prepare-apply-comment.outputs.COMMENT_BODY }}`; - // Also create a commit comment try { await github.rest.repos.createCommitComment({ owner: context.repo.owner, diff --git a/.github/workflows/helper-plan.yaml b/.github/workflows/helper-plan.yaml index f5a9ef1..5918064 100644 --- a/.github/workflows/helper-plan.yaml +++ b/.github/workflows/helper-plan.yaml @@ -123,56 +123,47 @@ jobs: echo "has_destroys=$has_destroys" >> $GITHUB_OUTPUT + - name: Prepare PR Comment Body + id: prepare-comment + if: github.event_name == 'pull_request' && steps.plan.outcome == 'success' + working-directory: ${{ inputs.terraform-directory }} + run: | + # Read plan summary + PLAN_SUMMARY=$(cat plan-summary.md) + + # Build comment header + HEADER="## Terraform Plan Results" + + # Add warning for destroys + WARNING="" + if [ "${{ steps.check-destroys.outputs.has_destroys }}" == "true" ]; then + WARNING="> āš ļø **WARNING: This plan contains DESTRUCTIVE changes (resource deletions)**" + fi + + # Add footer + FOOTER="--- +šŸ“ *To apply these changes: Merge this PR. Terraform will apply automatically after approval on the \`production\` environment.*" + + # Combine all parts + { + echo "COMMENT_BODY<> $GITHUB_OUTPUT + - name: Post Plan to PR if: github.event_name == 'pull_request' && steps.plan.outcome == 'success' - uses: actions/github-script@v7 + uses: ./.github/actions/pr-comment with: - script: | - const fs = require('fs'); - const planSummary = fs.readFileSync('${{ inputs.terraform-directory }}/plan-summary.md', 'utf8'); - - let header = '## Terraform Plan Results\n\n'; - - // Add warning for destroys - const hasDestroys = '${{ steps.check-destroys.outputs.has_destroys }}' === 'true'; - if (hasDestroys) { - header += '> āš ļø **WARNING: This plan contains DESTRUCTIVE changes (resource deletions)**\n\n'; - } - - // Add note about apply process - const footer = '\n\n---\nšŸ“ *To apply these changes: Merge this PR. Terraform will apply automatically after approval on the `production` environment.*'; - - const body = header + planSummary + footer; - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('šŸ—ļø Terraform Plan Results') - ); - - if (botComment) { - // Update existing comment - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - // Create new comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } + body-includes: 'Terraform Plan Results' + comment-body: ${{ steps.prepare-comment.outputs.COMMENT_BODY }} + issue-number: ${{ github.event.pull_request.number }} + private-key: ${{ secrets.ORG_GITHUB_APP_PRIVATE_KEY }} - name: Upload Plan Artifact if: inputs.upload-plan && steps.plan.outputs.exitcode == '2' diff --git a/.github/workflows/terraform-apply.yaml b/.github/workflows/terraform-apply.yaml index cd8e18b..f75533e 100644 --- a/.github/workflows/terraform-apply.yaml +++ b/.github/workflows/terraform-apply.yaml @@ -66,7 +66,12 @@ jobs: runs-on: ubuntu-latest needs: [detect-changes, apply] if: always() && needs.detect-changes.outputs.has-terraform-changes == 'true' + env: + TF_SLACK_CHANNEL_ID: 'alerts' steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Summary run: | echo "## šŸ—ļø Terraform Apply Summary" >> $GITHUB_STEP_SUMMARY @@ -89,6 +94,28 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "šŸ”— [View commit](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})" >> $GITHUB_STEP_SUMMARY + - name: Notify Slack of Apply Success + if: needs.apply.result == 'success' + uses: ./.github/actions/slack-notify + with: + slack_bot_token: ${{ secrets.ORG_SLACK_BOT_TOKEN }} + channel_id: ${{ env.TF_SLACK_CHANNEL_ID }} + title: 'Terraform Apply Successful' + message: 'Infrastructure changes have been applied successfully to the production environment.' + status: 'success' + commit_sha: ${{ github.sha }} + + - name: Notify Slack of Apply Failure + if: needs.apply.result == 'failure' + uses: ./.github/actions/slack-notify + with: + slack_bot_token: ${{ secrets.ORG_SLACK_BOT_TOKEN }} + channel_id: ${{ env.TF_SLACK_CHANNEL_ID }} + title: 'Terraform Apply Failed' + message: 'The infrastructure apply has failed. Please review the workflow logs immediately.' + status: 'failure' + commit_sha: ${{ github.sha }} + notify-skip: name: Notify No Changes runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 33ac6ef..6335d4f 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,19 @@ update-roles: # Update WebKit Ansible roles to latest @echo "WebKit roles updated to latest version" .PHONY: update-roles +sync-actions: # Sync GitHub Actions from WebKit submodule + @echo "Syncing GitHub Actions from vendor/webkit..." + @mkdir -p .github/actions + @rm -rf .github/actions/pr-comment .github/actions/slack-notify + @cp -r vendor/webkit/internal/templates/.github/actions/pr-comment .github/actions/ + @cp -r vendor/webkit/internal/templates/.github/actions/slack-notify .github/actions/ + @echo "āœ“ Actions synced successfully" + @echo " - pr-comment: .github/actions/pr-comment/" + @echo " - slack-notify: .github/actions/slack-notify/" + @echo "" + @echo "Run this command after updating the webkit submodule to get the latest action improvements." +.PHONY: sync-actions + plan: # Run Terraform plan terraform -chdir=$(TF_DIR) plan -var-file=$(TFVARS) .PHONY: plan