From 562ac4222506922450c2723707a35afe1989669b Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 01:02:53 -0600 Subject: [PATCH 1/6] failure notification on the daily sync --- .github/workflows/assets/notify.sh | 93 ++++++++++++++++++++++ .github/workflows/heartbeat.yml | 8 +- .github/workflows/sync-translation.yml | 34 ++++++++- CHANGELOG.md | 3 + README.md | 1 + docs/GETTING-STARTED.md | 3 +- scripts/lint.sh | 1 + tests/helpers/common.bash | 7 ++ tests/helpers/http_mock.bash | 52 +++++++++++++ tests/test_notify.bats | 102 +++++++++++++++++++++++++ 10 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/assets/notify.sh create mode 100644 tests/test_notify.bats diff --git a/.github/workflows/assets/notify.sh b/.github/workflows/assets/notify.sh new file mode 100644 index 0000000..7b2ff6d --- /dev/null +++ b/.github/workflows/assets/notify.sh @@ -0,0 +1,93 @@ +# shellcheck shell=bash +# Slack failure notifications for sync-translation and heartbeat workflows. +# Source env.sh before notify.sh when using workflow globals (e.g. HEARTBEAT_MAX_AGE_HOURS). + +# Return workflow run URL for the given run id (defaults to GITHUB_RUN_ID). +workflow_run_url() { + local run_id="${1:-${GITHUB_RUN_ID:-}}" + local server="${GITHUB_SERVER_URL:-https://github.com}" + echo "${server}/${GITHUB_REPOSITORY}/actions/runs/${run_id}" +} + +# Extract matrix lang from a sync-local job name, e.g. "sync-local (zh_Hans)" → zh_Hans. +# Prints nothing for non-matrix job names (e.g. discover). +extract_sync_local_lang_from_job_name() { + local job_name="$1" + local lang + lang="$(sed -n 's/.*sync-local[[:space:]]*(\([^)]*\)).*/\1/p' <<<"$job_name")" + if [[ -n "$lang" && "$lang" != "$job_name" ]]; then + echo "$lang" + fi +} + +# Read gh api /actions/runs/.../jobs JSON from stdin; emit human-readable failed-job lines. +format_failed_jobs_summary() { + jq -r ' + (if type == "object" and has("jobs") then .jobs else . end) + | .[] + | select(.conclusion == "failure") + | select(.name | test("notify-failure") | not) + | .name as $name + | ($name | if test("sync-local \\(") + then (capture("sync-local \\((?[^)]+)\\)") | .lang // "") + else "" end) as $lang + | if ($lang | length) > 0 then " • \($name) — lang=\($lang)" + else " • \($name)" end + ' +} + +# Build a Slack incoming-webhook JSON payload. Args: title run_url summary_lines detail (optional). +build_slack_failure_payload() { + local title="$1" run_url="$2" summary="$3" detail="${4:-}" + local text="${title}"$'\n'"Run: ${run_url}" + if [[ -n "$summary" ]]; then + text+=$'\n'"Failed jobs:"$'\n'"${summary}" + fi + if [[ -n "$detail" ]]; then + text+=$'\n'"${detail}" + fi + jq -n --arg text "$text" '{text: $text}' +} + +# POST JSON payload to SLACK_WEBHOOK_URL. +send_slack_notification() { + local payload="$1" + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "error: SLACK_WEBHOOK_URL secret is not set." >&2 + return 1 + fi + curl -fsS -X POST -H 'Content-Type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" +} + +# Fetch failed jobs for a workflow run and format summary lines. +collect_workflow_failed_jobs_summary() { + local run_id="${1:-${GITHUB_RUN_ID:-}}" + gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs \ + | format_failed_jobs_summary +} + +# Notify Slack about sync-translation workflow failures (discover or sync-local). +notify_sync_translation_failure() { + local run_id="${1:-${GITHUB_RUN_ID:-}}" + local run_url summary payload + run_url="$(workflow_run_url "$run_id")" + summary="$(collect_workflow_failed_jobs_summary "$run_id")" + payload="$(build_slack_failure_payload "Sync translation failed" "$run_url" "$summary" "")" + send_slack_notification "$payload" +} + +# Notify Slack when the daily sync heartbeat is stale. +notify_heartbeat_stale() { + local last_ts="$1" + local run_url workflow_url detail payload + local server="${GITHUB_SERVER_URL:-https://github.com}" + local last_desc="${last_ts:-none on record}" + + run_url="$(workflow_run_url)" + workflow_url="${server}/${GITHUB_REPOSITORY}/actions/workflows/sync-translation.yml" + detail="Daily sync heartbeat: last successful scheduled run is stale (last=${last_desc}, threshold=${HEARTBEAT_MAX_AGE_HOURS}h). The scheduled sync may have stopped." + detail+=$'\n'"Workflow: ${workflow_url}" + + payload="$(build_slack_failure_payload "Sync heartbeat alert" "$run_url" "" "$detail")" + send_slack_notification "$payload" +} diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index cb3ae33..e2cda37 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -6,6 +6,8 @@ # alerts when the last *successful* scheduled sync is older than a threshold, so a # silently missed run is caught. It does not depend on sync-translation.yml # running, so it still fires once that workflow has stopped. +# +# Required secret: SLACK_WEBHOOK_URL — Slack incoming webhook for stale-heartbeat alerts. name: Sync heartbeat @@ -31,6 +33,7 @@ jobs: - name: Check the last successful scheduled sync env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail @@ -38,6 +41,8 @@ jobs: source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" # shellcheck source=assets/lib.sh source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh" + # shellcheck source=assets/notify.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/notify.sh" begin_phase "$PHASE_HEARTBEAT" "Check daily sync heartbeat" trap '[[ -n "$CURRENT_PHASE" ]] && end_phase' EXIT ERR @@ -61,10 +66,9 @@ jobs: max_age_seconds=$(( HEARTBEAT_MAX_AGE_HOURS * 3600 )) if heartbeat_run_is_stale "$last_epoch" "$now_epoch" "$max_age_seconds"; then - # Interim alert: fail the job. Swap this for the shared notification - # channel once the failure-notification work provides one. last_desc="${last_ts:-none on record}" echo "::error::Daily sync heartbeat: last successful scheduled run is stale (last=${last_desc}, threshold=${HEARTBEAT_MAX_AGE_HOURS}h). The scheduled sync may have stopped." >&2 + notify_heartbeat_stale "$last_ts" end_phase exit 1 fi diff --git a/.github/workflows/sync-translation.yml b/.github/workflows/sync-translation.yml index b85b578..03f8def 100644 --- a/.github/workflows/sync-translation.yml +++ b/.github/workflows/sync-translation.yml @@ -11,8 +11,10 @@ # Manual trigger: POST /repos/{owner}/{repo}/dispatches # Body: {"event_type": "sync-translation"} # -# Required secret: SYNC_TOKEN — PAT with repo push to this repository. -# Submodule remotes in .gitmodules point at per-library org (set when running add-submodules / start-translation with SUBMODULES_ORG). +# Required secrets: +# SYNC_TOKEN — PAT with repo push to this repository. +# Submodule remotes in .gitmodules point at per-library org (set when running add-submodules / start-translation with SUBMODULES_ORG). +# SLACK_WEBHOOK_URL — Slack incoming webhook for failure alerts (notify-failure job). name: Sync translation @@ -148,3 +150,31 @@ jobs: end_phase echo "Done." >&2 + + notify-failure: + needs: [discover, sync-local] + if: >- + always() && + (needs.discover.result == 'failure' || needs.sync-local.result == 'failure') + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - name: Checkout repository (master) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Notify Slack on failure + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # shellcheck source=assets/env.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" + # shellcheck source=assets/notify.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/notify.sh" + + notify_sync_translation_failure diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fdcefd..3b48ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ are a separate namespace — see [README](README.md#releases) and - Lint/CI schema checks via pinned `check-jsonschema` (metaschema + fixture validation); bats validates captured Weblate request bodies against the schema and asserts success response fields. +- Slack failure notifications for `sync-translation` (via `notify-failure` job) + and stale-sync alerts for `heartbeat` (shared `notify.sh` + `SLACK_WEBHOOK_URL` + secret). ### Changed diff --git a/README.md b/README.md index 3613167..7351f2a 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ documented below. | `SYNC_TOKEN` | all workflows | PAT with **`repo`** scope; **`add-submodules`** also needs permission to create org repositories when creating new mirrors. | | `WEBLATE_URL` | `start-translation` | Base URL of the Weblate instance (the workflow appends **`WEBLATE_ENDPOINT_PATH`**). | | `WEBLATE_TOKEN` | `start-translation` | API token for that endpoint. | +| `SLACK_WEBHOOK_URL` | `sync-translation`, `heartbeat` | Slack incoming webhook URL for failure and stale-heartbeat alerts. | ## Repository variables diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 1e4fd2c..148395e 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -55,10 +55,11 @@ Configure these on the translations repository **before** dispatching any workfl | Secret | `SYNC_TOKEN` | [README § Required secrets](../README.md#required-secrets) | | Secret | `WEBLATE_URL` | [README § Required secrets](../README.md#required-secrets) | | Secret | `WEBLATE_TOKEN` | [README § Required secrets](../README.md#required-secrets) | +| Secret | `SLACK_WEBHOOK_URL` | [README § Required secrets](../README.md#required-secrets) — Slack incoming webhook for `sync-translation` failure and `heartbeat` stale-sync alerts | | Variable | `LANG_CODES` | [README § Repository variables](../README.md#repository-variables) | | Variable | `SUBMODULES_ORG` | [README § Repository variables](../README.md#repository-variables) | -`SYNC_TOKEN`, `WEBLATE_URL`, and `WEBLATE_TOKEN` are required secrets; +`SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, and `SLACK_WEBHOOK_URL` are required secrets; `LANG_CODES` and optional `SUBMODULES_ORG` are repository variables. See the linked README sections for scope, format, and which workflows consume each value. diff --git a/scripts/lint.sh b/scripts/lint.sh index bf86e24..aa5a7e6 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -169,6 +169,7 @@ echo "lint: actionlint ${ACTIONLINT_VERSION} ($("$ACTIONLINT_BIN" -version | hea "$SHELLCHECK_BIN" -x \ .github/workflows/assets/env.sh \ .github/workflows/assets/lib.sh \ + .github/workflows/assets/notify.sh \ .github/workflows/assets/translation.sh \ .github/workflows/assets/add_submodules.sh \ .github/workflows/assets/submodule_ops.sh \ diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 7289db3..43def34 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -41,6 +41,13 @@ load_submodule_ops() { source "$ASSETS_DIR/submodule_ops.sh" } +load_notify() { + load_env + export GITHUB_RUN_ID="${GITHUB_RUN_ID:-12345}" + # shellcheck source=/dev/null + source "$ASSETS_DIR/notify.sh" +} + # Run a function and capture its exit code (works under set -e in callers). run_fn() { local errexit_was_on=0 diff --git a/tests/helpers/http_mock.bash b/tests/helpers/http_mock.bash index b464d29..af51432 100644 --- a/tests/helpers/http_mock.bash +++ b/tests/helpers/http_mock.bash @@ -144,4 +144,56 @@ restore_curl_stub() { rm -rf "$CURL_WRAPPER_DIR" fi unset CURL_WRAPPER_DIR REAL_CURL _CURL_ORIG_PATH MOCK_CURL_TIMEOUT MOCK_CURL_EXIT + unset MOCK_SLACK_REQUEST_LOG MOCK_SLACK_STATUS +} + +install_slack_curl_stub() { + _CURL_ORIG_PATH="$PATH" + local wrapper_dir + wrapper_dir="$(mktemp -d)" + REAL_CURL="$(command -v curl)" + export REAL_CURL + MOCK_SLACK_REQUEST_LOG="$(mktemp)" + export MOCK_SLACK_REQUEST_LOG + MOCK_SLACK_STATUS="${MOCK_SLACK_STATUS:-200}" + export MOCK_SLACK_STATUS + cat >"$wrapper_dir/curl" <<'EOF' +#!/usr/bin/env bash +if [[ -n "${MOCK_CURL_EXIT:-}" ]]; then + echo "mock curl: simulated exit ${MOCK_CURL_EXIT}" >&2 + exit "$MOCK_CURL_EXIT" +fi +if [[ "${MOCK_CURL_TIMEOUT:-}" == "1" ]]; then + echo "mock curl: simulated timeout" >&2 + exit 28 +fi +if [[ -n "${SLACK_WEBHOOK_URL:-}" && " $* " == *" ${SLACK_WEBHOOK_URL} "* ]]; then + body="" + prev="" + for arg in "$@"; do + if [[ "$prev" == "--data" || "$prev" == "-d" ]]; then + body="$arg" + fi + prev="$arg" + done + { + echo "URL=$SLACK_WEBHOOK_URL" + echo "BODY_START" + printf '%s' "$body" + echo + echo "BODY_END" + } >"${MOCK_SLACK_REQUEST_LOG}" + exit 0 +fi +exec "$REAL_CURL" "$@" +EOF + chmod +x "$wrapper_dir/curl" + CURL_WRAPPER_DIR="$wrapper_dir" + export PATH="$wrapper_dir:$PATH" +} + +restore_slack_curl_stub() { + [[ -n "${MOCK_SLACK_REQUEST_LOG:-}" && -f "$MOCK_SLACK_REQUEST_LOG" ]] && rm -f "$MOCK_SLACK_REQUEST_LOG" + unset MOCK_SLACK_REQUEST_LOG MOCK_SLACK_STATUS + restore_curl_stub } diff --git a/tests/test_notify.bats b/tests/test_notify.bats new file mode 100644 index 0000000..7dccd5c --- /dev/null +++ b/tests/test_notify.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + load_notify +} + +teardown() { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + restore_slack_curl_stub +} + +@test "extract_sync_local_lang_from_job_name: parses matrix lang" { + run extract_sync_local_lang_from_job_name "sync-local (zh_Hans)" + [ "$status" -eq 0 ] + [ "$output" = "zh_Hans" ] +} + +@test "extract_sync_local_lang_from_job_name: parses prefixed workflow job name" { + run extract_sync_local_lang_from_job_name "Sync translation / sync-local (ja)" + [ "$status" -eq 0 ] + [ "$output" = "ja" ] +} + +@test "extract_sync_local_lang_from_job_name: discover returns empty" { + run extract_sync_local_lang_from_job_name "discover" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "format_failed_jobs_summary: lists failed discover and sync-local jobs with langs" { + local fixture + fixture='{ + "jobs": [ + {"name": "discover", "conclusion": "failure"}, + {"name": "sync-local (zh_Hans)", "conclusion": "failure"}, + {"name": "sync-local (ja)", "conclusion": "failure"}, + {"name": "sync-local (en)", "conclusion": "success"}, + {"name": "notify-failure", "conclusion": "failure"} + ] + }' + run bash -c "source '${ASSETS_DIR}/notify.sh'; format_failed_jobs_summary" <<<"$fixture" + [ "$status" -eq 0 ] + [[ "$output" == *"• discover"* ]] + [[ "$output" == *"sync-local (zh_Hans) — lang=zh_Hans"* ]] + [[ "$output" == *"sync-local (ja) — lang=ja"* ]] + [[ "$output" != *"sync-local (en)"* ]] + [[ "$output" != *"notify-failure"* ]] +} + +@test "build_slack_failure_payload: valid JSON with run URL and summary" { + run build_slack_failure_payload \ + "Sync translation failed" \ + "https://github.com/org/repo/actions/runs/99" \ + $' • discover\n • sync-local (ja) — lang=ja' \ + [ "$status" -eq 0 ] + echo "$output" | jq -e . >/dev/null + [[ "$output" == *"https://github.com/org/repo/actions/runs/99"* ]] + [[ "$output" == *"Sync translation failed"* ]] + [[ "$output" == *"lang=ja"* ]] +} + +@test "send_slack_notification: missing SLACK_WEBHOOK_URL exits 1" { + unset SLACK_WEBHOOK_URL + run send_slack_notification '{"text":"hi"}' + [ "$status" -eq 1 ] + [[ "$output" == *"SLACK_WEBHOOK_URL"* ]] +} + +@test "send_slack_notification: posts JSON to webhook URL" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + run send_slack_notification '{"text":"alert"}' + [ "$status" -eq 0 ] + grep -q 'URL=https://hooks.slack.com/services/T/B/x' "$MOCK_SLACK_REQUEST_LOG" + grep -q '"text":"alert"' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "workflow_run_url: uses GITHUB_SERVER_URL and GITHUB_REPOSITORY" { + export GITHUB_SERVER_URL="https://github.example" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="42" + run workflow_run_url + [ "$status" -eq 0 ] + [ "$output" = "https://github.example/myorg/myrepo/actions/runs/42" ] +} + +@test "notify_heartbeat_stale: builds payload with threshold detail" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + run notify_heartbeat_stale "2026-01-01T00:00:00Z" + [ "$status" -eq 0 ] + grep -q 'Sync heartbeat alert' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'threshold=30h' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'sync-translation.yml' "$MOCK_SLACK_REQUEST_LOG" +} From 2de973dd1d119cba5aa68b8ddd5bb71a8a019fa7 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 01:04:36 -0600 Subject: [PATCH 2/6] add persist-credentials: false and a permissions: block to add-submodules.yml --- .github/workflows/add-submodules.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/add-submodules.yml b/.github/workflows/add-submodules.yml index a8379b8..6221006 100644 --- a/.github/workflows/add-submodules.yml +++ b/.github/workflows/add-submodules.yml @@ -25,6 +25,9 @@ concurrency: group: add-submodules-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: add-submodules: runs-on: ubuntu-latest @@ -33,6 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.SYNC_TOKEN }} + persist-credentials: false - name: Add submodules shell: bash From 3aa8a530a298bf46e75c9bf941416dae97d2f7b7 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 01:20:23 -0600 Subject: [PATCH 3/6] fix the coderabbitai review comments --- .github/workflows/assets/notify.sh | 3 ++- .github/workflows/sync-translation.yml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/assets/notify.sh b/.github/workflows/assets/notify.sh index 7b2ff6d..bfb8f09 100644 --- a/.github/workflows/assets/notify.sh +++ b/.github/workflows/assets/notify.sh @@ -56,7 +56,8 @@ send_slack_notification() { echo "error: SLACK_WEBHOOK_URL secret is not set." >&2 return 1 fi - curl -fsS -X POST -H 'Content-Type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" + curl -fsS --connect-timeout 10 --max-time 30 \ + -X POST -H 'Content-Type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" } # Fetch failed jobs for a workflow run and format summary lines. diff --git a/.github/workflows/sync-translation.yml b/.github/workflows/sync-translation.yml index 03f8def..bfada20 100644 --- a/.github/workflows/sync-translation.yml +++ b/.github/workflows/sync-translation.yml @@ -158,6 +158,7 @@ jobs: (needs.discover.result == 'failure' || needs.sync-local.result == 'failure') runs-on: ubuntu-latest permissions: + contents: read actions: read steps: - name: Checkout repository (master) From 09f8237f3c05cdde97b2cb6ec6eefb5b0eb92009 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 02:05:29 -0600 Subject: [PATCH 4/6] fix the coderabbitai review comments --- docs/GETTING-STARTED.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 148395e..19ad738 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -61,7 +61,9 @@ Configure these on the translations repository **before** dispatching any workfl `SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, and `SLACK_WEBHOOK_URL` are required secrets; `LANG_CODES` and optional `SUBMODULES_ORG` are repository variables. See the -linked README sections for scope, format, and which workflows consume each value. +linked README sections for scope and format. **`sync-translation`** requires +**`SYNC_TOKEN`** for `discover` and `sync-local`; its `notify-failure` job requires +**`SLACK_WEBHOOK_URL`**. --- @@ -271,8 +273,8 @@ For each remote **`${LOCAL_BRANCH_PREFIX}*`** branch in the super-repo: checkout with submodules, set each submodule's tracking branch to that name, run `git submodule update --remote`, commit if pointers changed, and force-push. -Requires secret **`SYNC_TOKEN`** only. Relies on **`.gitmodules`** URLs established -by steps 2–3. +Requires secrets **`SYNC_TOKEN`** (`discover`, `sync-local`) and **`SLACK_WEBHOOK_URL`** +(`notify-failure`). Relies on **`.gitmodules`** URLs established by steps 2–3. ### Verify From 3a52790d54c30c0cbe735d9f6a6be8d2b604e23a Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 03:17:22 -0600 Subject: [PATCH 5/6] fix the first reviewer comments --- .github/workflows/assets/notify.sh | 24 ++++++++---- .github/workflows/heartbeat.yml | 2 +- README.md | 2 +- docs/GETTING-STARTED.md | 2 +- tests/helpers/http_mock.bash | 59 +++++++++++++++--------------- tests/test_notify.bats | 2 +- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/.github/workflows/assets/notify.sh b/.github/workflows/assets/notify.sh index bfb8f09..f119d0b 100644 --- a/.github/workflows/assets/notify.sh +++ b/.github/workflows/assets/notify.sh @@ -2,11 +2,20 @@ # Slack failure notifications for sync-translation and heartbeat workflows. # Source env.sh before notify.sh when using workflow globals (e.g. HEARTBEAT_MAX_AGE_HOURS). +resolve_run_id() { + echo "${1:-${GITHUB_RUN_ID:-}}" +} + +github_actions_url() { + local path="$1" + echo "${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/actions/${path}" +} + # Return workflow run URL for the given run id (defaults to GITHUB_RUN_ID). workflow_run_url() { - local run_id="${1:-${GITHUB_RUN_ID:-}}" - local server="${GITHUB_SERVER_URL:-https://github.com}" - echo "${server}/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + local run_id + run_id="$(resolve_run_id "$1")" + github_actions_url "runs/${run_id}" } # Extract matrix lang from a sync-local job name, e.g. "sync-local (zh_Hans)" → zh_Hans. @@ -62,14 +71,16 @@ send_slack_notification() { # Fetch failed jobs for a workflow run and format summary lines. collect_workflow_failed_jobs_summary() { - local run_id="${1:-${GITHUB_RUN_ID:-}}" + local run_id + run_id="$(resolve_run_id "$1")" gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs \ | format_failed_jobs_summary } # Notify Slack about sync-translation workflow failures (discover or sync-local). notify_sync_translation_failure() { - local run_id="${1:-${GITHUB_RUN_ID:-}}" + local run_id + run_id="$(resolve_run_id "$1")" local run_url summary payload run_url="$(workflow_run_url "$run_id")" summary="$(collect_workflow_failed_jobs_summary "$run_id")" @@ -81,11 +92,10 @@ notify_sync_translation_failure() { notify_heartbeat_stale() { local last_ts="$1" local run_url workflow_url detail payload - local server="${GITHUB_SERVER_URL:-https://github.com}" local last_desc="${last_ts:-none on record}" run_url="$(workflow_run_url)" - workflow_url="${server}/${GITHUB_REPOSITORY}/actions/workflows/sync-translation.yml" + workflow_url="$(github_actions_url "workflows/sync-translation.yml")" detail="Daily sync heartbeat: last successful scheduled run is stale (last=${last_desc}, threshold=${HEARTBEAT_MAX_AGE_HOURS}h). The scheduled sync may have stopped." detail+=$'\n'"Workflow: ${workflow_url}" diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index e2cda37..a7457f2 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository (master) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/README.md b/README.md index 7351f2a..2949b99 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ documented below. | Secret | Used by | Description | | --------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `SYNC_TOKEN` | all workflows | PAT with **`repo`** scope; **`add-submodules`** also needs permission to create org repositories when creating new mirrors. | +| `SYNC_TOKEN` | `add-submodules`, `start-translation`, `sync-translation` | PAT with **`repo`** scope; **`add-submodules`** also needs permission to create org repositories when creating new mirrors. | | `WEBLATE_URL` | `start-translation` | Base URL of the Weblate instance (the workflow appends **`WEBLATE_ENDPOINT_PATH`**). | | `WEBLATE_TOKEN` | `start-translation` | API token for that endpoint. | | `SLACK_WEBHOOK_URL` | `sync-translation`, `heartbeat` | Slack incoming webhook URL for failure and stale-heartbeat alerts. | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 19ad738..30e2210 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -33,7 +33,7 @@ flowchart TD | Step | When | Workflow | Trigger | Script / client | | ---- | ---- | -------- | ------- | ----------------- | -| 0 | Always first | — | — | GitHub secrets/vars: `SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, `LANG_CODES`, optional `SUBMODULES_ORG` | +| 0 | Always first | — | — | GitHub secrets/vars: `SYNC_TOKEN`, `WEBLATE_URL`, `WEBLATE_TOKEN`, `SLACK_WEBHOOK_URL`, `LANG_CODES`, optional `SUBMODULES_ORG` | | 1 | Optional | — | — | `cp .env.example .env` → `GH_TOKEN` | | 2 | Greenfield / new libs | `add-submodules.yml` | `event_type: add-submodules` | `scripts/trigger-add-submodules.sh` | | 3 | After mirrors exist | `start-translation.yml` | `event_type: start-translation` | `scripts/trigger-start-translation.sh` | diff --git a/tests/helpers/http_mock.bash b/tests/helpers/http_mock.bash index af51432..e89bdf7 100644 --- a/tests/helpers/http_mock.bash +++ b/tests/helpers/http_mock.bash @@ -113,13 +113,20 @@ install_curl_timeout_stub() { install_curl_stub } -install_curl_stub() { +_prepare_curl_stub_wrapper_dir() { _CURL_ORIG_PATH="$PATH" - local wrapper_dir - wrapper_dir="$(mktemp -d)" + CURL_WRAPPER_DIR="$(mktemp -d)" REAL_CURL="$(command -v curl)" - export REAL_CURL - cat >"$wrapper_dir/curl" <<'EOF' + export REAL_CURL CURL_WRAPPER_DIR +} + +_activate_curl_stub_wrapper() { + chmod +x "$CURL_WRAPPER_DIR/curl" + export PATH="$CURL_WRAPPER_DIR:$PATH" +} + +_curl_stub_wrapper_preamble() { + cat <<'EOF' #!/usr/bin/env bash if [[ -n "${MOCK_CURL_EXIT:-}" ]]; then echo "mock curl: simulated exit ${MOCK_CURL_EXIT}" >&2 @@ -129,11 +136,18 @@ if [[ "${MOCK_CURL_TIMEOUT:-}" == "1" ]]; then echo "mock curl: simulated timeout" >&2 exit 28 fi +EOF +} + +install_curl_stub() { + _prepare_curl_stub_wrapper_dir + { + _curl_stub_wrapper_preamble + cat <<'EOF' exec "$REAL_CURL" "$@" EOF - chmod +x "$wrapper_dir/curl" - CURL_WRAPPER_DIR="$wrapper_dir" - export PATH="$wrapper_dir:$PATH" + } >"$CURL_WRAPPER_DIR/curl" + _activate_curl_stub_wrapper } restore_curl_stub() { @@ -144,29 +158,15 @@ restore_curl_stub() { rm -rf "$CURL_WRAPPER_DIR" fi unset CURL_WRAPPER_DIR REAL_CURL _CURL_ORIG_PATH MOCK_CURL_TIMEOUT MOCK_CURL_EXIT - unset MOCK_SLACK_REQUEST_LOG MOCK_SLACK_STATUS } install_slack_curl_stub() { - _CURL_ORIG_PATH="$PATH" - local wrapper_dir - wrapper_dir="$(mktemp -d)" - REAL_CURL="$(command -v curl)" - export REAL_CURL + _prepare_curl_stub_wrapper_dir MOCK_SLACK_REQUEST_LOG="$(mktemp)" export MOCK_SLACK_REQUEST_LOG - MOCK_SLACK_STATUS="${MOCK_SLACK_STATUS:-200}" - export MOCK_SLACK_STATUS - cat >"$wrapper_dir/curl" <<'EOF' -#!/usr/bin/env bash -if [[ -n "${MOCK_CURL_EXIT:-}" ]]; then - echo "mock curl: simulated exit ${MOCK_CURL_EXIT}" >&2 - exit "$MOCK_CURL_EXIT" -fi -if [[ "${MOCK_CURL_TIMEOUT:-}" == "1" ]]; then - echo "mock curl: simulated timeout" >&2 - exit 28 -fi + { + _curl_stub_wrapper_preamble + cat <<'EOF' if [[ -n "${SLACK_WEBHOOK_URL:-}" && " $* " == *" ${SLACK_WEBHOOK_URL} "* ]]; then body="" prev="" @@ -187,13 +187,12 @@ if [[ -n "${SLACK_WEBHOOK_URL:-}" && " $* " == *" ${SLACK_WEBHOOK_URL} "* ]]; th fi exec "$REAL_CURL" "$@" EOF - chmod +x "$wrapper_dir/curl" - CURL_WRAPPER_DIR="$wrapper_dir" - export PATH="$wrapper_dir:$PATH" + } >"$CURL_WRAPPER_DIR/curl" + _activate_curl_stub_wrapper } restore_slack_curl_stub() { [[ -n "${MOCK_SLACK_REQUEST_LOG:-}" && -f "$MOCK_SLACK_REQUEST_LOG" ]] && rm -f "$MOCK_SLACK_REQUEST_LOG" - unset MOCK_SLACK_REQUEST_LOG MOCK_SLACK_STATUS + unset MOCK_SLACK_REQUEST_LOG restore_curl_stub } diff --git a/tests/test_notify.bats b/tests/test_notify.bats index 7dccd5c..c8c2745 100644 --- a/tests/test_notify.bats +++ b/tests/test_notify.bats @@ -54,7 +54,7 @@ teardown() { run build_slack_failure_payload \ "Sync translation failed" \ "https://github.com/org/repo/actions/runs/99" \ - $' • discover\n • sync-local (ja) — lang=ja' \ + $' • discover\n • sync-local (ja) — lang=ja' [ "$status" -eq 0 ] echo "$output" | jq -e . >/dev/null [[ "$output" == *"https://github.com/org/repo/actions/runs/99"* ]] From 5ef75977d184dac4b80fd090617289d51cea5916 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Fri, 31 Jul 2026 10:14:43 -0600 Subject: [PATCH 6/6] fix the second reviewer comments --- .github/workflows/assets/notify.sh | 8 ++--- tests/helpers/common.bash | 1 + tests/helpers/mock_gh.bash | 16 +++++++++ tests/test_notify.bats | 53 ++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.github/workflows/assets/notify.sh b/.github/workflows/assets/notify.sh index f119d0b..2b6fc2a 100644 --- a/.github/workflows/assets/notify.sh +++ b/.github/workflows/assets/notify.sh @@ -14,7 +14,7 @@ github_actions_url() { # Return workflow run URL for the given run id (defaults to GITHUB_RUN_ID). workflow_run_url() { local run_id - run_id="$(resolve_run_id "$1")" + run_id="$(resolve_run_id "${1:-}")" github_actions_url "runs/${run_id}" } @@ -72,7 +72,7 @@ send_slack_notification() { # Fetch failed jobs for a workflow run and format summary lines. collect_workflow_failed_jobs_summary() { local run_id - run_id="$(resolve_run_id "$1")" + run_id="$(resolve_run_id "${1:-}")" gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs \ | format_failed_jobs_summary } @@ -80,10 +80,10 @@ collect_workflow_failed_jobs_summary() { # Notify Slack about sync-translation workflow failures (discover or sync-local). notify_sync_translation_failure() { local run_id - run_id="$(resolve_run_id "$1")" + run_id="$(resolve_run_id "${1:-}")" local run_url summary payload run_url="$(workflow_run_url "$run_id")" - summary="$(collect_workflow_failed_jobs_summary "$run_id")" + summary="$(collect_workflow_failed_jobs_summary "$run_id")" || summary="(details unavailable)" payload="$(build_slack_failure_payload "Sync translation failed" "$run_url" "$summary" "")" send_slack_notification "$payload" } diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 43def34..c0a98ea 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -44,6 +44,7 @@ load_submodule_ops() { load_notify() { load_env export GITHUB_RUN_ID="${GITHUB_RUN_ID:-12345}" + set -euo pipefail # shellcheck source=/dev/null source "$ASSETS_DIR/notify.sh" } diff --git a/tests/helpers/mock_gh.bash b/tests/helpers/mock_gh.bash index c9d7052..02e7fa8 100644 --- a/tests/helpers/mock_gh.bash +++ b/tests/helpers/mock_gh.bash @@ -60,6 +60,19 @@ case "$cmd" in exit "${MOCK_PR_LIST_EXIT:-0}" fi ;; + run) + if [[ "${1:-}" == "view" ]]; then + if [[ "${MOCK_RUN_VIEW_EXIT:-0}" -ne 0 ]]; then + exit "${MOCK_RUN_VIEW_EXIT}" + fi + if [[ -n "${MOCK_RUN_VIEW_JSON:-}" ]]; then + printf '%s\n' "$MOCK_RUN_VIEW_JSON" + else + printf '%s\n' '{"jobs":[]}' + fi + exit 0 + fi + ;; esac echo "mock gh: unhandled invocation: gh $*" >&2 @@ -80,13 +93,16 @@ restore_mock_gh() { unset MOCK_REPO_VIEW_EXIT MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT unset MOCK_REPO_CREATE_EXIT MOCK_GH_PATCH_LOG MOCK_GH_PATCH_EXIT unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + unset MOCK_RUN_VIEW_EXIT MOCK_RUN_VIEW_JSON } reset_mock_gh() { export MOCK_REPO_VIEW_EXIT=0 export MOCK_GH_PATCH_EXIT=0 + export MOCK_RUN_VIEW_EXIT=0 unset MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT MOCK_REPO_CREATE_EXIT unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + unset MOCK_RUN_VIEW_JSON export MOCK_PR_LIST_EXIT=0 if [[ -n "${MOCK_GH_DIR:-}" ]]; then MOCK_GH_PATCH_LOG="$MOCK_GH_DIR/gh-patch.log" diff --git a/tests/test_notify.bats b/tests/test_notify.bats index c8c2745..59fbdbd 100644 --- a/tests/test_notify.bats +++ b/tests/test_notify.bats @@ -10,6 +10,9 @@ teardown() { # shellcheck source=tests/helpers/http_mock.bash source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" restore_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + restore_mock_gh } @test "extract_sync_local_lang_from_job_name: parses matrix lang" { @@ -89,14 +92,64 @@ teardown() { [ "$output" = "https://github.example/myorg/myrepo/actions/runs/42" ] } +@test "notify_sync_translation_failure: no-arg call posts Slack under set -u" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + reset_mock_gh + export MOCK_RUN_VIEW_JSON='{"jobs":[{"name":"discover","conclusion":"failure"}]}' + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="4242" + run notify_sync_translation_failure + [ "$status" -eq 0 ] + grep -q 'Sync translation failed' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'actions/runs/4242' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'discover' "$MOCK_SLACK_REQUEST_LOG" +} + +@test "notify_sync_translation_failure: posts alert when gh run view fails" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + reset_mock_gh + export MOCK_RUN_VIEW_EXIT=1 + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_REPOSITORY="myorg/myrepo" + export GITHUB_RUN_ID="99" + run notify_sync_translation_failure + [ "$status" -eq 0 ] + grep -q 'Sync translation failed' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'details unavailable' "$MOCK_SLACK_REQUEST_LOG" +} + @test "notify_heartbeat_stale: builds payload with threshold detail" { # shellcheck source=tests/helpers/http_mock.bash source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" install_slack_curl_stub export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + export GITHUB_RUN_ID="42" run notify_heartbeat_stale "2026-01-01T00:00:00Z" [ "$status" -eq 0 ] grep -q 'Sync heartbeat alert' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'last=2026-01-01T00:00:00Z' "$MOCK_SLACK_REQUEST_LOG" grep -q 'threshold=30h' "$MOCK_SLACK_REQUEST_LOG" + grep -q 'actions/runs/42' "$MOCK_SLACK_REQUEST_LOG" grep -q 'sync-translation.yml' "$MOCK_SLACK_REQUEST_LOG" } + +@test "notify_heartbeat_stale: empty timestamp uses none on record" { + # shellcheck source=tests/helpers/http_mock.bash + source "$BATS_TEST_DIRNAME/helpers/http_mock.bash" + install_slack_curl_stub + export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T/B/x" + run notify_heartbeat_stale "" + [ "$status" -eq 0 ] + grep -q 'last=none on record' "$MOCK_SLACK_REQUEST_LOG" +}