Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
104 changes: 104 additions & 0 deletions .github/workflows/assets/notify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 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).

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
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.
# 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 --connect-timeout 10 --max-time 30 \
-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
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
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")"
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 last_desc="${last_ts:-none on record}"

run_url="$(workflow_run_url)"
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}"

payload="$(build_slack_failure_payload "Sync heartbeat alert" "$run_url" "" "$detail")"
send_slack_notification "$payload"
}
10 changes: 7 additions & 3 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 @@ -24,20 +26,23 @@ 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

- 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
35 changes: 33 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,32 @@ 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:
contents: read
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@ 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. |

## Repository variables

Expand Down
13 changes: 8 additions & 5 deletions docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -55,12 +55,15 @@ 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.
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`**.

---

Expand Down Expand Up @@ -270,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

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
67 changes: 59 additions & 8 deletions tests/helpers/http_mock.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -145,3 +159,40 @@ restore_curl_stub() {
fi
unset CURL_WRAPPER_DIR REAL_CURL _CURL_ORIG_PATH MOCK_CURL_TIMEOUT MOCK_CURL_EXIT
}

install_slack_curl_stub() {
_prepare_curl_stub_wrapper_dir
MOCK_SLACK_REQUEST_LOG="$(mktemp)"
export MOCK_SLACK_REQUEST_LOG
{
_curl_stub_wrapper_preamble
cat <<'EOF'
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
} >"$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
restore_curl_stub
}
Loading