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