Skip to content
Open
Show file tree
Hide file tree
Changes from all 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