Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .github/workflows/assets/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ if [[ -z "${_ENV_SH_LOADED:-}" ]]; then
readonly PHASE_TRIGGER_WEBLATE="trigger-weblate"
readonly PHASE_DISCOVER="discover"
readonly PHASE_SYNC_POINTERS="sync-pointers"
readonly PHASE_HEARTBEAT="heartbeat"

# Missed-run heartbeat: alert when the last successful scheduled sync is older
# than this. Margin above the 24h cron cadence so a delayed run does not trip the alert.
readonly HEARTBEAT_MAX_AGE_HOURS=30

# start-translation submodule mode (START_PHASE env).
readonly START_PHASE_MIRRORS="mirrors"
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/assets/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,12 @@ validate_secrets() {
_require_nonempty WEBLATE_TOKEN "WEBLATE_TOKEN secret is not set."
fi
}

# True (exit 0) when the last successful scheduled sync is missing or older than
# max_age_seconds, i.e. the daily run has silently stopped. Pure function; the
# heartbeat workflow supplies the timestamps from the GitHub API.
heartbeat_run_is_stale() {
local last_success_epoch="$1" now_epoch="$2" max_age_seconds="$3"
[[ -z "$last_success_epoch" || "$last_success_epoch" == "0" ]] && return 0
(( now_epoch - last_success_epoch > max_age_seconds ))
}
71 changes: 71 additions & 0 deletions .github/workflows/heartbeat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Missed-run heartbeat for the daily "Sync translation" cron.
#
# A failed sync run surfaces on its own, but a run that never fires is silent:
# GitHub can drop scheduled runs, auto-disable the workflow after repo inactivity,
# or the schedule can be removed. This workflow runs on its own schedule and
# 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.

name: Sync heartbeat

on:
schedule:
# Noon UTC, offset from the sync's 00:00 run so a healthy day reads ~12h old.
- cron: "0 12 * * *"
workflow_dispatch:

permissions:
contents: read
actions: read

jobs:
check-heartbeat:
runs-on: ubuntu-latest
steps:
- name: Checkout repository (master)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Check the last successful scheduled sync
env:
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/lib.sh
source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh"

begin_phase "$PHASE_HEARTBEAT" "Check daily sync heartbeat"
trap '[[ -n "$CURRENT_PHASE" ]] && end_phase' EXIT ERR

last_ts="$(gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow sync-translation.yml \
--event schedule \
--status success \
--limit 1 \
--json updatedAt \
--jq '.[0].updatedAt // ""')"
Comment thread
timon0305 marked this conversation as resolved.
Outdated

last_epoch=""
if [[ -n "$last_ts" ]]; then
last_epoch="$(date -d "$last_ts" +%s)"
fi
now_epoch="$(date +%s)"
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
end_phase
exit 1
fi

echo "Heartbeat healthy: last successful scheduled sync at ${last_ts} (within ${HEARTBEAT_MAX_AGE_HOURS}h)." >&2
end_phase
17 changes: 17 additions & 0 deletions tests/test_lib.bats
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,20 @@ teardown() {
[[ "$output" == *"Failed — processing error (1): sync_failed"* ]]
[[ "$output" != *"processing error (2)"* ]]
}

@test "heartbeat_run_is_stale: stale when no successful run is on record" {
heartbeat_run_is_stale "" 200000 100000
heartbeat_run_is_stale "0" 200000 100000
}

@test "heartbeat_run_is_stale: stale when the last success is older than the threshold" {
heartbeat_run_is_stale 50000 200000 100000
}

@test "heartbeat_run_is_stale: fresh when the last success is within the threshold" {
! heartbeat_run_is_stale 150000 200000 100000
}

@test "heartbeat_run_is_stale: at the threshold boundary is still fresh (strict older-than)" {
! heartbeat_run_is_stale 100000 200000 100000
}