Skip to content

Commit fc7bc90

Browse files
authored
feat(ci): missed-run heartbeat for the daily sync cron (#72)
* feat(ci): missed-run heartbeat for the daily sync cron (#71) 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 inactivity, or lose the schedule. Add a separate scheduled workflow (heartbeat.yml) that checks the last successful scheduled sync-translation run and alerts when it is older than a threshold, so a silently missed run is caught. It does not depend on the sync workflow running, so it still fires once that workflow has stopped. The staleness decision lives in a pure heartbeat_run_is_stale() in lib.sh with bats coverage; HEARTBEAT_MAX_AGE_HOURS (30) carries margin above the 24h cadence. Alerting is interim (fails the job); point it at the shared notification channel once the failure-notification work lands. * fix(ci): use the scheduled run's createdAt for the heartbeat age updatedAt moves when an old run is rerun, so a stopped schedule whose last run was manually rerun looked healthy. Query createdAt, the schedule-fire time, so the age reflects when the run actually fired.
1 parent 8882125 commit fc7bc90

4 files changed

Lines changed: 104 additions & 0 deletions

File tree

.github/workflows/assets/env.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ if [[ -z "${_ENV_SH_LOADED:-}" ]]; then
1515
readonly PHASE_TRIGGER_WEBLATE="trigger-weblate"
1616
readonly PHASE_DISCOVER="discover"
1717
readonly PHASE_SYNC_POINTERS="sync-pointers"
18+
readonly PHASE_HEARTBEAT="heartbeat"
19+
20+
# Missed-run heartbeat: alert when the last successful scheduled sync is older
21+
# than this. Margin above the 24h cron cadence so a delayed run does not trip the alert.
22+
readonly HEARTBEAT_MAX_AGE_HOURS=30
1823

1924
# start-translation submodule mode (START_PHASE env).
2025
readonly START_PHASE_MIRRORS="mirrors"

.github/workflows/assets/lib.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,12 @@ validate_secrets() {
559559
_require_nonempty WEBLATE_TOKEN "WEBLATE_TOKEN secret is not set."
560560
fi
561561
}
562+
563+
# True (exit 0) when the last successful scheduled sync is missing or older than
564+
# max_age_seconds, i.e. the daily run has silently stopped. Pure function; the
565+
# heartbeat workflow supplies the timestamps from the GitHub API.
566+
heartbeat_run_is_stale() {
567+
local last_success_epoch="$1" now_epoch="$2" max_age_seconds="$3"
568+
[[ -z "$last_success_epoch" || "$last_success_epoch" == "0" ]] && return 0
569+
(( now_epoch - last_success_epoch > max_age_seconds ))
570+
}

.github/workflows/heartbeat.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Missed-run heartbeat for the daily "Sync translation" cron.
2+
#
3+
# A failed sync run surfaces on its own, but a run that never fires is silent:
4+
# GitHub can drop scheduled runs, auto-disable the workflow after repo inactivity,
5+
# or the schedule can be removed. This workflow runs on its own schedule and
6+
# alerts when the last *successful* scheduled sync is older than a threshold, so a
7+
# silently missed run is caught. It does not depend on sync-translation.yml
8+
# running, so it still fires once that workflow has stopped.
9+
10+
name: Sync heartbeat
11+
12+
on:
13+
schedule:
14+
# Noon UTC, offset from the sync's 00:00 run so a healthy day reads ~12h old.
15+
- cron: "0 12 * * *"
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
actions: read
21+
22+
jobs:
23+
check-heartbeat:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout repository (master)
27+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
28+
with:
29+
persist-credentials: false
30+
31+
- name: Check the last successful scheduled sync
32+
env:
33+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
run: |
35+
set -euo pipefail
36+
37+
# shellcheck source=assets/env.sh
38+
source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh"
39+
# shellcheck source=assets/lib.sh
40+
source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh"
41+
42+
begin_phase "$PHASE_HEARTBEAT" "Check daily sync heartbeat"
43+
trap '[[ -n "$CURRENT_PHASE" ]] && end_phase' EXIT ERR
44+
45+
# createdAt is when the run was scheduled; updatedAt moves when an old
46+
# run is rerun, which would make a stopped schedule look healthy.
47+
last_ts="$(gh run list \
48+
--repo "$GITHUB_REPOSITORY" \
49+
--workflow sync-translation.yml \
50+
--event schedule \
51+
--status success \
52+
--limit 1 \
53+
--json createdAt \
54+
--jq '.[0].createdAt // ""')"
55+
56+
last_epoch=""
57+
if [[ -n "$last_ts" ]]; then
58+
last_epoch="$(date -d "$last_ts" +%s)"
59+
fi
60+
now_epoch="$(date +%s)"
61+
max_age_seconds=$(( HEARTBEAT_MAX_AGE_HOURS * 3600 ))
62+
63+
if heartbeat_run_is_stale "$last_epoch" "$now_epoch" "$max_age_seconds"; then
64+
# Interim alert: fail the job. Swap this for the shared notification
65+
# channel once the failure-notification work provides one.
66+
last_desc="${last_ts:-none on record}"
67+
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
68+
end_phase
69+
exit 1
70+
fi
71+
72+
echo "Heartbeat healthy: last successful scheduled sync at ${last_ts} (within ${HEARTBEAT_MAX_AGE_HOURS}h)." >&2
73+
end_phase

tests/test_lib.bats

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,20 @@ teardown() {
568568
[[ "$output" == *"Failed — processing error (1): sync_failed"* ]]
569569
[[ "$output" != *"processing error (2)"* ]]
570570
}
571+
572+
@test "heartbeat_run_is_stale: stale when no successful run is on record" {
573+
heartbeat_run_is_stale "" 200000 100000
574+
heartbeat_run_is_stale "0" 200000 100000
575+
}
576+
577+
@test "heartbeat_run_is_stale: stale when the last success is older than the threshold" {
578+
heartbeat_run_is_stale 50000 200000 100000
579+
}
580+
581+
@test "heartbeat_run_is_stale: fresh when the last success is within the threshold" {
582+
! heartbeat_run_is_stale 150000 200000 100000
583+
}
584+
585+
@test "heartbeat_run_is_stale: at the threshold boundary is still fresh (strict older-than)" {
586+
! heartbeat_run_is_stale 100000 200000 100000
587+
}

0 commit comments

Comments
 (0)