diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..79c3395 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,57 @@ +name: "Test" + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Notification script tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Run tests + run: bash tests/run-tests.sh + + - name: Check the action still passes inputs through the environment + run: | + python3 -c " + import yaml + step = yaml.safe_load(open('action.yaml'))['runs']['steps'][0] + required = { + 'INPUT_WEBHOOK_URL', 'INPUT_NOTIFICATION_TYPE', 'INPUT_CHANNEL', + 'INPUT_WORKFLOW_NAME', 'INPUT_JOB_RESULTS', 'GITHUB_CONTEXT', + } + missing = required - set(step.get('env', {})) + assert not missing, f'inputs no longer reach the script via env: {missing}' + body = step['run'] + assert 'inputs.' not in body, f'run body interpolates an input directly: {body}' + " + + # Separate job so NOTIFY_DRY_RUN cannot leak into the suite above, whose + # webhook cases must make a real request to the local sink. + smoke: + name: Action wiring smoke test + runs-on: ubuntu-latest + env: + NOTIFY_DRY_RUN: "1" + steps: + - uses: actions/checkout@v5 + + # Proves action.yaml actually reaches scripts/notify.sh. A packaging + # mistake here would otherwise only surface in the consuming repos. + - name: Invoke the action + uses: ./ + with: + webhook-url: "unused-in-dry-run" + notification-type: 1 + workflow-name: "Self Test" + job-results: "smoke:${{ job.status }}" + github-context: ${{ toJSON(github) }} diff --git a/README.md b/README.md index 4eae1d0..8376557 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CI/CD Notification Action -A reusable GitHub Action for sending workflow status notifications to Mattermost or Slack. Supports all GitHub Actions job statuses: success ✅, failure ❌, cancelled ◻️, and skipped ⏭️. +A reusable GitHub Action for sending workflow status notifications to Mattermost or Slack. Supports all GitHub Actions job statuses: success ✅, failure ❌, cancelled ⚫, and skipped ⏭️. ## Status Indicators @@ -8,13 +8,22 @@ A reusable GitHub Action for sending workflow status notifications to Mattermost |--------|-------|-------|-------------| | Success | ✅ | Green (#00FF00) | All jobs completed successfully | | Failure | ❌ | Red (#FF0000) | One or more jobs failed | -| Cancelled | ◻️ | Gray (#808080) | Workflow was cancelled | +| Cancelled | ⚫ | Gray (#808080) | Workflow was cancelled | | Skipped | ⏭️ | Orange (#FFA500) | Jobs were skipped | +`success` is reported only when every job result is exactly `success`. Otherwise the +worst status wins, in the order `failure` > `cancelled` > `skipped`, and any value outside +that set is reported as ❓ UNKNOWN rather than folded into green. + +The action **fails closed**: `job-results` that parses to nothing, or an entry without a +non-empty status, exits the step non-zero instead of sending a notification. A silent +fallthrough to green is what let comma-separated `job-results` report success over failing +builds. + ## Usage ```yaml - uses: Wire-Network/cicd-notifications/.github/workflows/notification.yaml@v1 + uses: Wire-Network/notification-action@v1 with: webhook-url: ${{ secrets.WEBHOOK_URL }} # ... other inputs @@ -46,10 +55,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Send Notification - uses: Wire-Network/cicd-notifications/.github/workflows/notification.yaml@v1 + uses: Wire-Network/notification-action@v1 with: webhook-url: ${{ secrets.WEBHOOK_URL }} - notification-type: mattermost + notification-type: 1 channel: cicd-notifications workflow-name: "Build & Test Workflow" job-results: "build-and-test:${{ needs.build-and-test.result }}" @@ -91,10 +100,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Send Notification - uses: wire-network/cicd-notifications@v1 + uses: Wire-Network/notification-action@v1 with: webhook-url: ${{ secrets.WEBHOOK_URL }} - notification-type: mattermost + notification-type: 1 channel: cicd-notifications workflow-name: "Build & Test Workflow" job-results: | @@ -131,15 +140,16 @@ Simply change the `notification-type` to `2` and provide the Slack channel ID: | Input | Required | Default | Description | |-------|----------|---------|-------------| | `webhook-url` | Yes | - | Webhook URL for Slack or Mattermost | -| `notification-type` | Yes | `mattermost` | Type of notification service (1 or 2) | +| `notification-type` | Yes | `1` | Notification service: `1` for Mattermost, `2` for Slack | | `channel` | No | `cicd-notifications` | Channel name (Mattermost) or channel ID (Slack) | | `workflow-name` | Yes | - | Name of the workflow (e.g., `Build & Test Workflow`) | -| `job-results` | Yes | - | Job results in `job:status` format (space or newline-separated) or JSON | +| `job-results` | Yes | - | Job results as `job:status` pairs (comma-, space- or newline-separated) or a JSON object | | `github-context` | Yes | - | JSON string of GitHub context | ### Job Results Format -You can pass job results in two formats: +You can pass job results in two formats. Entries may be separated by any mix of +commas, spaces and newlines. **Simple format (recommended):** @@ -156,12 +166,31 @@ Or inline for single job: job-results: "build-and-test:${{ needs.build-and-test.result }}" ``` +Or comma-separated on one line: + +```yaml +job-results: "tests:${{ needs.tests.result }},build:${{ needs.build.result }}" +``` + **JSON format (also supported):** ```yaml job-results: '{"tests":"success","build":"failure","deploy":"skipped"}' ``` +## Development + +`scripts/notify.sh` holds the implementation; `action.yaml` only passes the inputs +through the environment. Run the suite with: + +```bash +tests/run-tests.sh +``` + +It exercises every separator form, the status precedence, the fail-closed paths, the +rendered payload and the webhook call itself against a local sink. `NOTIFY_DRY_RUN=1` +prints the payload to stdout and skips the webhook. + ## Tips 1. **Always use `if: always()`** on the notification job to ensure it runs even if other jobs fail diff --git a/action.yaml b/action.yaml index d7a23aa..eb44c55 100644 --- a/action.yaml +++ b/action.yaml @@ -18,244 +18,29 @@ inputs: description: 'Name of the workflow (e.g., "Build & Test Workflow")' required: true job-results: - description: 'Job results - either JSON string {"job":"status"} or space/newline-separated "job:status" pairs' + description: >- + Job results, as a JSON object {"job":"status"} or as `job:status` pairs + separated by commas, spaces or newlines. Every entry must carry a + non-empty status; input that parses to nothing is a hard error rather + than a green notification. required: true github-context: description: 'GitHub context as JSON (use toJSON(github))' required: true - runs: using: 'composite' steps: - name: Send Notification shell: bash + # Inputs cross into the script through the environment, never through + # `${{ }}` interpolation into the run body: workflow names, branch names + # and PR titles routinely contain quotes and shell metacharacters. env: + INPUT_WEBHOOK_URL: ${{ inputs.webhook-url }} + INPUT_NOTIFICATION_TYPE: ${{ inputs.notification-type }} + INPUT_CHANNEL: ${{ inputs.channel }} + INPUT_WORKFLOW_NAME: ${{ inputs.workflow-name }} + INPUT_JOB_RESULTS: ${{ inputs.job-results }} GITHUB_CONTEXT: ${{ inputs.github-context }} - run: | - set -e - - echo "Starting notification action..." - - # Get inputs - WEBHOOK_URL="${{ inputs.webhook-url }}" - NOTIFICATION_TYPE="${{ inputs.notification-type }}" - CHANNEL="${{ inputs.channel }}" - WORKFLOW_NAME="${{ inputs.workflow-name }}" - JOB_RESULTS="${{ inputs.job-results }}" - - echo "Inputs:" - echo " Notification Type: $NOTIFICATION_TYPE" - echo " Channel: $CHANNEL" - echo " Workflow Name: $WORKFLOW_NAME" - echo " Job Results: $JOB_RESULTS" - - # GITHUB_CONTEXT is passed via env block to avoid shell interpolation issues - # (PR bodies can contain parentheses, ampersands, quotes, etc.) - - REPOSITORY=$(echo "$GITHUB_CONTEXT" | jq -r '.repository') - REF_NAME=$(echo "$GITHUB_CONTEXT" | jq -r '.ref_name') - HEAD_REF=$(echo "$GITHUB_CONTEXT" | jq -r '.head_ref // ""') - COMMIT_SHA=$(echo "$GITHUB_CONTEXT" | jq -r '.sha') - ACTOR=$(echo "$GITHUB_CONTEXT" | jq -r '.actor') - RUN_ID=$(echo "$GITHUB_CONTEXT" | jq -r '.run_id') - SERVER_URL=$(echo "$GITHUB_CONTEXT" | jq -r '.server_url') - EVENT_NAME=$(echo "$GITHUB_CONTEXT" | jq -r '.event_name') - PR_NUMBER=$(echo "$GITHUB_CONTEXT" | jq -r '.event.pull_request.number // ""') - PR_URL=$(echo "$GITHUB_CONTEXT" | jq -r '.event.pull_request.html_url // ""') - - echo "" - echo "GitHub Context:" - echo " Repository: $REPOSITORY" - echo " Event: $EVENT_NAME" - echo " Ref Name: $REF_NAME" - echo " Commit: ${COMMIT_SHA:0:7}" - echo " Actor: $ACTOR" - - # Determine branch name - if [[ "$EVENT_NAME" == "pull_request" ]]; then - BRANCH="$HEAD_REF" - echo " Branch: $BRANCH (from PR head ref)" - else - BRANCH="$REF_NAME" - echo " Branch: $BRANCH (from ref name)" - fi - - echo "" - echo "Parsing job results..." - # Parse job results - support both JSON and simple format - if [[ "$JOB_RESULTS" =~ ^\{.*\}$ ]]; then - JOB_RESULTS_JSON="$JOB_RESULTS" - echo " Input is JSON format" - else - echo " Converting to JSON..." - JOB_RESULTS_JSON="{" - FIRST=true - for pair in $JOB_RESULTS; do - if [[ "$pair" =~ ^([^:]+):([^:]+)$ ]]; then - job="${BASH_REMATCH[1]}" - status="${BASH_REMATCH[2]}" - echo " $job: $status" - if [ "$FIRST" = true ]; then - JOB_RESULTS_JSON="${JOB_RESULTS_JSON}\"${job}\":\"${status}\"" - FIRST=false - else - JOB_RESULTS_JSON="${JOB_RESULTS_JSON},\"${job}\":\"${status}\"" - fi - fi - done - JOB_RESULTS_JSON="${JOB_RESULTS_JSON}}" - echo " Result: $JOB_RESULTS_JSON" - fi - - # Function to get emoji and text for status - get_status_info() { - case "$1" in - success) - echo "✅|SUCCESS|#00FF00|good" - ;; - failure) - echo "❌|FAILURE|#FF0000|danger" - ;; - cancelled) - echo "⚫|CANCELLED|#808080|#808080" - ;; - skipped) - echo "⏭️|SKIPPED|#FFA500|warning" - ;; - *) - echo "❓|UNKNOWN|#808080|#808080" - ;; - esac - } - - # Function to format job name for display - format_job_name() { - echo "$1" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1' - } - - echo "" - echo "Determining overall status..." - # Determine overall status (priority: failure > cancelled > skipped > success) - if echo "$JOB_RESULTS_JSON" | jq -e '[.[] | select(. == "failure")] | length > 0' > /dev/null 2>&1; then - OVERALL_STATUS="failure" - echo " Overall: FAILURE" - elif echo "$JOB_RESULTS_JSON" | jq -e '[.[] | select(. == "cancelled")] | length > 0' > /dev/null 2>&1; then - OVERALL_STATUS="cancelled" - echo " Overall: CANCELLED" - elif echo "$JOB_RESULTS_JSON" | jq -e '[.[] | select(. == "skipped")] | length > 0' > /dev/null 2>&1; then - OVERALL_STATUS="skipped" - echo " Overall: SKIPPED" - else - OVERALL_STATUS="success" - echo " Overall: SUCCESS" - fi - - # Get status display info - IFS='|' read -r STATUS_EMOJI STATUS_TEXT COLOR SLACK_COLOR <<< "$(get_status_info "$OVERALL_STATUS")" - echo " Emoji: $STATUS_EMOJI" - echo " Color: $COLOR" - - # Build job status details (only show non-success jobs) - JOB_DETAILS="" - echo "$JOB_RESULTS_JSON" | jq -r 'to_entries | .[] | "\(.key)|\(.value)"' | while IFS='|' read -r job result; do - if [[ "$result" != "success" ]]; then - JOB_NAME=$(format_job_name "$job") - JOB_STATUS_INFO=$(get_status_info "$result") - JOB_EMOJI=$(echo "$JOB_STATUS_INFO" | cut -d'|' -f1) - - if [[ -z "$JOB_DETAILS" ]]; then - JOB_DETAILS="\n- **${JOB_NAME}**: ${JOB_EMOJI} ${result}" - else - JOB_DETAILS="${JOB_DETAILS}\n- **${JOB_NAME}**: ${JOB_EMOJI} ${result}" - fi - fi - done - - echo "" - # Add PR info if present - PR_INFO="" - if [[ -n "$PR_NUMBER" && "$PR_NUMBER" != "null" && "$PR_NUMBER" != "" ]]; then - PR_INFO="\n**PR**: [#${PR_NUMBER}](${PR_URL})" - echo "PR: #$PR_NUMBER" - else - echo "No PR information" - fi - - # Short commit SHA - SHORT_SHA="${COMMIT_SHA:0:7}" - - # Build URLs - COMMIT_URL="${SERVER_URL}/${REPOSITORY}/commit/${COMMIT_SHA}" - RUN_URL="${SERVER_URL}/${REPOSITORY}/actions/runs/${RUN_ID}" - REPO_URL="${SERVER_URL}/${REPOSITORY}" - BRANCH_URL="${SERVER_URL}/${REPOSITORY}/tree/${BRANCH}" - - echo "" - echo "Generated URLs:" - echo " Repository: $REPO_URL" - echo " Branch: $BRANCH_URL" - echo " Commit: $COMMIT_URL" - echo " Workflow: $RUN_URL" - - echo "" - echo "Building payload..." - # Send notification based on type - if [[ "$NOTIFICATION_TYPE" == "2" ]]; then - echo " Target: Slack" - # Slack payload - PAYLOAD="{ - \"channel\": \"$CHANNEL\", - \"username\": \"GitHub Actions\", - \"icon_url\": \"https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png\", - \"attachments\": [{ - \"color\": \"$SLACK_COLOR\", - \"title\": \"$STATUS_EMOJI $STATUS_TEXT: $WORKFLOW_NAME\", - \"text\": \"*Repository:* <$REPO_URL|$REPOSITORY>\\n*Branch:* <$BRANCH_URL|$BRANCH>${PR_INFO}\\n*Commit:* <$COMMIT_URL|\`$SHORT_SHA\`>\\n*Triggered by:* $ACTOR\\n*Workflow Run:* <$RUN_URL|View Details>${JOB_DETAILS}\", - \"footer\": \"GitHub Actions\", - \"footer_icon\": \"https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png\" - }] - }" - else - echo " Target: Mattermost" - # Mattermost payload - PAYLOAD="{ - \"channel\": \"$CHANNEL\", - \"username\": \"GitHub Actions\", - \"icon_url\": \"https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png\", - \"attachments\": [{ - \"color\": \"$COLOR\", - \"title\": \"$STATUS_EMOJI $STATUS_TEXT: $WORKFLOW_NAME\", - \"text\": \"**Repository**: [$REPOSITORY]($REPO_URL)\\n**Branch**: [$BRANCH]($BRANCH_URL)${PR_INFO}\\n**Commit**: [\`$SHORT_SHA\`]($COMMIT_URL)\\n**Triggered by**: $ACTOR\\n**Workflow Run**: [View Details]($RUN_URL)${JOB_DETAILS}\" - }] - }" - fi - - echo "" - echo "Payload preview:" - echo "$PAYLOAD" | jq . 2>/dev/null || { - echo " (jq formatting failed)" - echo " Channel: $CHANNEL" - echo " Status: $STATUS_TEXT" - } - - echo "" - echo "Sending notification..." - # Send the notification - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD") - - echo " HTTP Response: $HTTP_STATUS" - - if [[ "$HTTP_STATUS" -ge 200 && "$HTTP_STATUS" -lt 300 ]]; then - echo "" - echo "Notification sent successfully!" - else - echo "" - echo "Notification failed with HTTP $HTTP_STATUS" - echo "" - echo "Debug - Payload sent:" - printf '%s\n' "$PAYLOAD" - exit 1 - fi \ No newline at end of file + run: bash "${{ github.action_path }}/scripts/notify.sh" diff --git a/scripts/notify.sh b/scripts/notify.sh new file mode 100755 index 0000000..42d70bd --- /dev/null +++ b/scripts/notify.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# +# Build and send a workflow-status notification to Mattermost or Slack. +# +# Every input arrives through the environment rather than through `${{ }}` +# interpolation into this script body: workflow names, branch names and PR +# titles routinely contain quotes and other shell metacharacters. +# +# Status derivation fails closed. SUCCESS is reported only when at least one +# job result parsed and every one of them is exactly "success"; anything +# unrecognised surfaces as UNKNOWN, and input that parses to nothing is a hard +# error. A silent fallthrough to green is what let comma-separated `job-results` +# report success over failing builds for months. +# +# Set NOTIFY_DRY_RUN=1 to print the payload and skip the webhook call. + +set -euo pipefail + +readonly SLACK_TYPE=2 + +WEBHOOK_URL="${INPUT_WEBHOOK_URL:-}" +NOTIFICATION_TYPE="${INPUT_NOTIFICATION_TYPE:-1}" +CHANNEL="${INPUT_CHANNEL:-cicd-notifications}" +WORKFLOW_NAME="${INPUT_WORKFLOW_NAME:-}" +JOB_RESULTS="${INPUT_JOB_RESULTS:-}" +GITHUB_CONTEXT="${GITHUB_CONTEXT:-}" +DRY_RUN="${NOTIFY_DRY_RUN:-}" + +# Informational output goes to stderr; stdout carries only the payload, which +# is what NOTIFY_DRY_RUN prints and what the tests assert on. +log() { + printf '%s\n' "$*" >&2 +} + +# `::error::` stays on stdout: that is where the runner reads workflow commands. +die() { + echo "::error::$*" + exit 1 +} + +[[ -n "$WORKFLOW_NAME" ]] || die "workflow-name is empty" +[[ -n "$GITHUB_CONTEXT" ]] || die "github-context is empty" +[[ -n "$WEBHOOK_URL" || -n "$DRY_RUN" ]] || die "webhook-url is empty" +jq -e . <<<"$GITHUB_CONTEXT" >/dev/null 2>&1 || die "github-context is not valid JSON" + +# Parse `job-results` into a {job: status} object, left in RESULTS_JSON. +# +# Accepts a JSON object, or `job:status` pairs separated by any mix of commas, +# spaces and newlines. Commas were never documented but both C++ build repos +# used them, so they are accepted rather than silently dropped. +# +# The result comes back through a global rather than a command substitution: +# `die` runs `exit`, which inside `$( )` would only end the subshell and would +# capture the `::error::` line into the caller's variable instead of logging it. +RESULTS_JSON="" +parse_job_results() { + local raw="$1" + + if [[ "$raw" =~ ^[[:space:]]*\{ ]]; then + jq -e 'type == "object" and length > 0 and all(.[]; type == "string" and length > 0)' \ + <<<"$raw" >/dev/null 2>&1 \ + || die "job-results looks like JSON but is not a non-empty object of non-empty strings: $raw" + RESULTS_JSON="$(jq -c . <<<"$raw")" + return + fi + + local parsed + parsed="$( + jq -Rn --arg raw "$raw" ' + ($raw | gsub("[,[:space:]]+"; "\n") | split("\n") | map(select(length > 0))) as $tokens + | { + ok: ($tokens | map(select(test("^[^:]+:[^:]+$"))) + | map((index(":")) as $i | {key: .[0:$i], value: .[$i + 1:]}) + | from_entries), + bad: ($tokens | map(select(test("^[^:]+:[^:]+$") | not))) + } + ' + )" + + local bad + bad="$(jq -r '.bad | join(", ")' <<<"$parsed")" + [[ -z "$bad" ]] || die "job-results entries are not \`job:status\`: ${bad}" + + jq -e '.ok | length > 0' <<<"$parsed" >/dev/null \ + || die "job-results parsed to nothing: '${raw}'" + + RESULTS_JSON="$(jq -c '.ok' <<<"$parsed")" +} + +# failure > cancelled > skipped > success, and anything else is UNKNOWN. +overall_status() { + local json="$1" + if jq -e 'any(.[]; . == "failure")' <<<"$json" >/dev/null; then + echo failure + elif jq -e 'any(.[]; . == "cancelled")' <<<"$json" >/dev/null; then + echo cancelled + elif jq -e 'any(.[]; . == "skipped")' <<<"$json" >/dev/null; then + echo skipped + elif jq -e 'length > 0 and all(.[]; . == "success")' <<<"$json" >/dev/null; then + echo success + else + echo unknown + fi +} + +# emoji|text|mattermost colour|slack colour +status_info() { + case "$1" in + success) echo "✅|SUCCESS|#00FF00|good" ;; + failure) echo "❌|FAILURE|#FF0000|danger" ;; + cancelled) echo "⚫|CANCELLED|#808080|#808080" ;; + skipped) echo "⏭️|SKIPPED|#FFA500|warning" ;; + *) echo "❓|UNKNOWN|#808080|#808080" ;; + esac +} + +format_job_name() { + echo "$1" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1' +} + +log "Parsing job results..." +log " Raw: $JOB_RESULTS" +parse_job_results "$JOB_RESULTS" +log " Parsed: $RESULTS_JSON" + +OVERALL_STATUS="$(overall_status "$RESULTS_JSON")" +log " Overall: $OVERALL_STATUS" + +IFS='|' read -r STATUS_EMOJI STATUS_TEXT COLOR SLACK_COLOR <<<"$(status_info "$OVERALL_STATUS")" + +REPOSITORY="$(jq -r '.repository' <<<"$GITHUB_CONTEXT")" +REF_NAME="$(jq -r '.ref_name' <<<"$GITHUB_CONTEXT")" +HEAD_REF="$(jq -r '.head_ref // ""' <<<"$GITHUB_CONTEXT")" +COMMIT_SHA="$(jq -r '.sha' <<<"$GITHUB_CONTEXT")" +ACTOR="$(jq -r '.actor' <<<"$GITHUB_CONTEXT")" +RUN_ID="$(jq -r '.run_id' <<<"$GITHUB_CONTEXT")" +SERVER_URL="$(jq -r '.server_url' <<<"$GITHUB_CONTEXT")" +EVENT_NAME="$(jq -r '.event_name' <<<"$GITHUB_CONTEXT")" +PR_NUMBER="$(jq -r '.event.pull_request.number // ""' <<<"$GITHUB_CONTEXT")" +PR_URL="$(jq -r '.event.pull_request.html_url // ""' <<<"$GITHUB_CONTEXT")" + +if [[ "$EVENT_NAME" == "pull_request" && -n "$HEAD_REF" ]]; then + BRANCH="$HEAD_REF" +else + BRANCH="$REF_NAME" +fi + +SHORT_SHA="${COMMIT_SHA:0:7}" +REPO_URL="${SERVER_URL}/${REPOSITORY}" +BRANCH_URL="${SERVER_URL}/${REPOSITORY}/tree/${BRANCH}" +COMMIT_URL="${SERVER_URL}/${REPOSITORY}/commit/${COMMIT_SHA}" +RUN_URL="${SERVER_URL}/${REPOSITORY}/actions/runs/${RUN_ID}" + +# Process substitution, not a pipe: a `while read` on the right of a pipe runs +# in a subshell and every JOB_DETAILS append is discarded when it exits. +JOB_DETAILS="" +while IFS=$'\t' read -r job result; do + if [[ "$result" != "success" ]]; then + job_emoji="$(status_info "$result" | cut -d'|' -f1)" + JOB_DETAILS+=" +- **$(format_job_name "$job")**: ${job_emoji} ${result}" + fi +done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' <<<"$RESULTS_JSON") + +TITLE="${STATUS_EMOJI} ${STATUS_TEXT}: ${WORKFLOW_NAME}" + +if [[ "$NOTIFICATION_TYPE" == "$SLACK_TYPE" ]]; then + PR_LINE="" + [[ -n "$PR_NUMBER" ]] && PR_LINE=" +*PR:* <${PR_URL}|#${PR_NUMBER}>" + TEXT="*Repository:* <${REPO_URL}|${REPOSITORY}> +*Branch:* <${BRANCH_URL}|${BRANCH}>${PR_LINE} +*Commit:* <${COMMIT_URL}|\`${SHORT_SHA}\`> +*Triggered by:* ${ACTOR} +*Workflow Run:* <${RUN_URL}|View Details>${JOB_DETAILS}" + + PAYLOAD="$( + jq -n --arg channel "$CHANNEL" --arg color "$SLACK_COLOR" --arg title "$TITLE" --arg text "$TEXT" \ + '{ + channel: $channel, + username: "GitHub Actions", + icon_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", + attachments: [{ + color: $color, + title: $title, + text: $text, + footer: "GitHub Actions", + footer_icon: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + }] + }' + )" +else + PR_LINE="" + [[ -n "$PR_NUMBER" ]] && PR_LINE=" +**PR**: [#${PR_NUMBER}](${PR_URL})" + TEXT="**Repository**: [${REPOSITORY}](${REPO_URL}) +**Branch**: [${BRANCH}](${BRANCH_URL})${PR_LINE} +**Commit**: [\`${SHORT_SHA}\`](${COMMIT_URL}) +**Triggered by**: ${ACTOR} +**Workflow Run**: [View Details](${RUN_URL})${JOB_DETAILS}" + + PAYLOAD="$( + jq -n --arg channel "$CHANNEL" --arg color "$COLOR" --arg title "$TITLE" --arg text "$TEXT" \ + '{ + channel: $channel, + username: "GitHub Actions", + icon_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", + attachments: [{ color: $color, title: $title, text: $text }] + }' + )" +fi + +if [[ -n "$DRY_RUN" ]]; then + printf '%s\n' "$PAYLOAD" + exit 0 +fi + +log "Sending notification..." +HTTP_STATUS="$( + curl -s -o /dev/null -w "%{http_code}" -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + --data-binary @- <<<"$PAYLOAD" +)" +log " HTTP Response: $HTTP_STATUS" + +if [[ "$HTTP_STATUS" -ge 200 && "$HTTP_STATUS" -lt 300 ]]; then + log "Notification sent successfully." +else + printf 'Payload sent:\n%s\n' "$PAYLOAD" >&2 + die "notification failed with HTTP ${HTTP_STATUS}" +fi diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..43ffa2d --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# +# Table-driven tests for scripts/notify.sh, run in dry-run mode so no webhook is +# contacted. Every case asserts on the payload the action would actually POST. + +set -uo pipefail + +readonly HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly NOTIFY="${HERE}/../scripts/notify.sh" + +PASS=0 +FAIL=0 + +readonly PR_CONTEXT='{ + "repository": "Wire-Network/wire-sysio", + "ref_name": "599/merge", + "head_ref": "feature/wire-367-producer-registration", + "sha": "c53fce9b1162720060594c3ad3d063af125bbff4", + "actor": "heifner", + "run_id": "34144933071", + "server_url": "https://github.com", + "event_name": "pull_request", + "event": { "pull_request": { "number": 599, "html_url": "https://github.com/Wire-Network/wire-sysio/pull/599" } } +}' + +readonly PUSH_CONTEXT='{ + "repository": "Wire-Network/wire-cdt", + "ref_name": "master", + "head_ref": "", + "sha": "532f3841b0000000000000000000000000000000", + "actor": "heifner", + "run_id": "1", + "server_url": "https://github.com", + "event_name": "push", + "event": {} +}' + +# run [notification-type] [context] [workflow-name] +run() { + NOTIFY_DRY_RUN=1 \ + INPUT_WEBHOOK_URL="" \ + INPUT_NOTIFICATION_TYPE="${2:-1}" \ + INPUT_CHANNEL="cicd-notifications" \ + INPUT_WORKFLOW_NAME="${4:-Build & Test Workflow}" \ + INPUT_JOB_RESULTS="$1" \ + GITHUB_CONTEXT="${3:-$PR_CONTEXT}" \ + bash "$NOTIFY" 2>/dev/null +} + +ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n %s\n' "$1" "$2"; } + +# assert_status +assert_status() { + local name="$1" input="$2" want="$3" + local out title + out="$(run "$input")" || { bad "$name" "script exited non-zero"; return; } + title="$(jq -r '.attachments[0].title' <<<"$out" 2>/dev/null)" + [[ "$title" == *"$want"* ]] && ok "$name" || bad "$name" "title was '${title}', wanted '*${want}*'" +} + +# assert_rejects +# +# A rejection must both exit non-zero and say why: `die` called from a command +# substitution ends only the subshell and its `::error::` line is captured into +# the caller's variable instead of reaching the log. +assert_rejects() { + local name="$1" input="$2" + local out status + out="$( + NOTIFY_DRY_RUN=1 \ + INPUT_WEBHOOK_URL="" \ + INPUT_NOTIFICATION_TYPE=1 \ + INPUT_WORKFLOW_NAME="Build & Test Workflow" \ + INPUT_JOB_RESULTS="$input" \ + GITHUB_CONTEXT="$PR_CONTEXT" \ + bash "$NOTIFY" 2>&1 + )" + status=$? + if [[ "$status" -eq 0 ]]; then + bad "$name" "expected a non-zero exit, got success" + elif [[ "$out" != *"::error::"* ]]; then + bad "$name" "exited non-zero but logged no ::error:: reason; output was: ${out}" + else + ok "$name" + fi +} + +echo "Separator handling" +# The regression: both C++ build repos passed commas, which parsed to {} and +# reported SUCCESS over failing builds. +assert_status "comma-separated, one failure -> FAILURE" \ + "build-test-package:failure,root-node-tooling:success,verify-packages:skipped" "❌ FAILURE" +assert_status "comma-separated, all success -> SUCCESS" \ + "discover:success,build-platforms:success,build:success" "✅ SUCCESS" +assert_status "space-separated, one failure -> FAILURE" \ + "platform-cache:success build-test-package:failure verify-packages:skipped" "❌ FAILURE" +assert_status "newline-separated (README) -> SKIPPED" \ + "$(printf 'tests:success\nnp-tests:skipped\n')" "⏭️ SKIPPED" +assert_status "mixed comma/space/newline -> FAILURE" \ + "$(printf 'a:success, b:failure\n c:success')" "❌ FAILURE" +assert_status "single pair -> SUCCESS" \ + "build-and-test:success" "✅ SUCCESS" +assert_status "JSON object (README) -> FAILURE" \ + '{"tests":"success","build":"failure","deploy":"skipped"}' "❌ FAILURE" + +echo +echo "Status precedence" +assert_status "failure beats cancelled" "a:cancelled b:failure" "❌ FAILURE" +assert_status "cancelled beats skipped" "a:skipped b:cancelled" "⚫ CANCELLED" +assert_status "skipped beats success" "a:success b:skipped" "⏭️ SKIPPED" + +echo +echo "Fails closed" +assert_status "unrecognised status is not SUCCESS" "a:success b:borked" "❓ UNKNOWN" +assert_rejects "empty job-results" "" +assert_rejects "no colon in entry" "build-test-package" +assert_rejects "empty status (bad needs)" "build-test-package:" +assert_rejects "JSON that is not an object" '{"nope"}' +assert_rejects "empty JSON object" '{}' + +echo +echo "Payload contents" +out="$(run "build:failure verify:success deploy:skipped")" +text="$(jq -r '.attachments[0].text' <<<"$out")" +[[ "$text" == *"- **Build**: ❌ failure"* ]] \ + && ok "failing job is listed in the body" \ + || bad "failing job is listed in the body" "body was: ${text}" +[[ "$text" == *"- **Deploy**: ⏭️ skipped"* ]] \ + && ok "skipped job is listed in the body" \ + || bad "skipped job is listed in the body" "body was: ${text}" +[[ "$text" != *"Verify"* ]] \ + && ok "successful job is not listed" \ + || bad "successful job is not listed" "body was: ${text}" +[[ "$(jq -r '.attachments[0].color' <<<"$out")" == "#FF0000" ]] \ + && ok "failure colour is red" \ + || bad "failure colour is red" "got $(jq -r '.attachments[0].color' <<<"$out")" + +echo +echo "Context handling" +out="$(run "a:success")" +[[ "$(jq -r '.attachments[0].text' <<<"$out")" == *"**PR**: [#599]"* ]] \ + && ok "PR event includes the PR line" || bad "PR event includes the PR line" "missing" +[[ "$(jq -r '.attachments[0].text' <<<"$out")" == *"feature/wire-367-producer-registration"* ]] \ + && ok "PR event uses head_ref as the branch" || bad "PR event uses head_ref as the branch" "missing" +out="$(run "a:success" 1 "$PUSH_CONTEXT")" +[[ "$(jq -r '.attachments[0].text' <<<"$out")" != *"**PR**"* ]] \ + && ok "push event omits the PR line" || bad "push event omits the PR line" "present" +[[ "$(jq -r '.attachments[0].text' <<<"$out")" == *"[master]"* ]] \ + && ok "push event uses ref_name as the branch" || bad "push event uses ref_name as the branch" "missing" + +echo +echo "Payload is valid JSON under hostile input" +out="$(run 'a:failure' 1 "$PR_CONTEXT" 'Build "quoted" & $(whoami) `id` \ Workflow')" +if jq -e . <<<"$out" >/dev/null 2>&1; then + title="$(jq -r '.attachments[0].title' <<<"$out")" + [[ "$title" == *'Build "quoted" & $(whoami) `id` \ Workflow'* ]] \ + && ok "quotes and metacharacters survive verbatim" \ + || bad "quotes and metacharacters survive verbatim" "title was: ${title}" +else + bad "quotes and metacharacters survive verbatim" "payload was not valid JSON: ${out}" +fi + +echo +echo "Slack payload" +out="$(run "a:failure" 2)" +[[ "$(jq -r '.attachments[0].color' <<<"$out")" == "danger" ]] \ + && ok "slack uses named colours" || bad "slack uses named colours" "got $(jq -r '.attachments[0].color' <<<"$out")" +[[ "$(jq -r '.attachments[0].text' <<<"$out")" == *""* ]] \ + && ok "slack uses links" || bad "slack uses links" "missing" + +echo +echo "Replay of wire-sysio run 34144933071 (gcc leg failed)" +assert_status "reports FAILURE, not SUCCESS" \ + "platform-cache:success discover-versions:success build-test-package:failure root-node-tooling:success verify-packages:skipped" \ + "❌ FAILURE" + +echo +echo "Webhook delivery" +# The one path the dry run cannot cover: the real curl call, and whether what +# lands on the wire is the JSON the receiver will accept. +SINK_PORT=8099 +SINK_LOG="$(mktemp)" +python3 "${HERE}/webhook-sink.py" "$SINK_PORT" "$SINK_LOG" & +SINK_PID=$! +trap 'kill "$SINK_PID" 2>/dev/null; rm -f "$SINK_LOG"' EXIT + +for _ in $(seq 1 50); do + curl -sf -X POST "http://127.0.0.1:${SINK_PORT}" \ + -H 'Content-Type: application/json' -d '{"attachments":[]}' >/dev/null 2>&1 && break + sleep 0.2 +done +: > "$SINK_LOG" + +if NOTIFY_DRY_RUN= \ + INPUT_WEBHOOK_URL="http://127.0.0.1:${SINK_PORT}" \ + INPUT_NOTIFICATION_TYPE=1 \ + INPUT_CHANNEL="cicd-notifications" \ + INPUT_WORKFLOW_NAME='Build "quoted" & $(whoami) Workflow' \ + INPUT_JOB_RESULTS="build:failure verify:success" \ + GITHUB_CONTEXT="$PR_CONTEXT" \ + bash "$NOTIFY" >/dev/null 2>&1; then + ok "posts to the webhook and accepts 2xx" +else + bad "posts to the webhook and accepts 2xx" "script exited non-zero" +fi + +delivered="$(cat "$SINK_LOG")" +if [[ -n "$delivered" ]] && jq -e . <<<"$delivered" >/dev/null 2>&1; then + ok "the receiver got well-formed JSON" + [[ "$(jq -r '.attachments[0].title' <<<"$delivered")" == *"❌ FAILURE"* ]] \ + && ok "the delivered payload carries the real status" \ + || bad "the delivered payload carries the real status" "title was $(jq -r '.attachments[0].title' <<<"$delivered")" +else + bad "the receiver got well-formed JSON" "sink recorded: ${delivered:-}" + bad "the delivered payload carries the real status" "nothing delivered" +fi + +# A webhook that does not answer 2xx must fail the step, not pass silently. +# Port 1 is closed, so curl reports 000. +if NOTIFY_DRY_RUN= \ + INPUT_WEBHOOK_URL="http://127.0.0.1:1/" \ + INPUT_NOTIFICATION_TYPE=1 \ + INPUT_WORKFLOW_NAME="W" \ + INPUT_JOB_RESULTS="a:success" \ + GITHUB_CONTEXT="$PUSH_CONTEXT" \ + bash "$NOTIFY" >/dev/null 2>&1; then + bad "an unreachable webhook fails the step" "expected non-zero exit" +else + ok "an unreachable webhook fails the step" +fi + +echo +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tests/webhook-sink.py b/tests/webhook-sink.py new file mode 100644 index 0000000..3b0d014 --- /dev/null +++ b/tests/webhook-sink.py @@ -0,0 +1,36 @@ +"""Minimal webhook sink used by the tests. + +Accepts a POST, rejects anything that is not a JSON object carrying an +``attachments`` array, and appends each accepted body to the file named by the +first argument so a test can assert on what the action actually sent. +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + try: + payload = json.loads(body) + assert isinstance(payload, dict), "payload is not a JSON object" + assert isinstance(payload["attachments"], list), "attachments is not a list" + except Exception as exc: # noqa: BLE001 - the test wants the reason + self.send_response(400) + self.end_headers() + self.wfile.write(str(exc).encode()) + return + with open(sys.argv[2], "a", encoding="utf-8") as handle: + handle.write(body.decode() + "\n") + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + HTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()