Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/add-submodules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ concurrency:
group: add-submodules-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
add-submodules:
runs-on: ubuntu-latest
Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/assets/notify.sh
Original file line number Diff line number Diff line change
@@ -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>[^)]+)\\)") | .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"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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"
}
8 changes: 6 additions & 2 deletions .github/workflows/heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -31,13 +33,16 @@ 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

# shellcheck source=assets/env.sh
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
Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions .github/workflows/sync-translation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
whisper67265 marked this conversation as resolved.
Outdated

Expand Down
1 change: 1 addition & 0 deletions scripts/lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
7 changes: 7 additions & 0 deletions tests/helpers/common.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/helpers/http_mock.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading