-
Notifications
You must be signed in to change notification settings - Fork 0
220 lines (202 loc) · 9.96 KB
/
Copy pathcron-freshness-check.yml
File metadata and controls
220 lines (202 loc) · 9.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
name: Cron - freshness check
# Cheap GET /api/health probe every 15 min UTC to detect cold-start
# regressions and freshness drops between ingest runs. No auth needed:
# the route is an unauthenticated uptime probe. If `status` is anything
# other than "ok" the step fails so Actions email/ops dashboards light
# up before a user notices stale data.
#
# Required repo config:
# vars.TRENDINGREPO_URL — optional; defaults to prod URL (legacy: vars.STARSCREENER_URL)
# secrets.OPS_ALERT_WEBHOOK — optional Discord/Slack webhook URL.
# When set, the workflow POSTs a one-line JSON message on state changes
# (healthy → stale and stale → healthy). Email-on-fail is too quiet,
# ops were missing alerts for days. State-change-only keeps it from
# becoming a 96-message/day spam-channel.
#
# State persistence: a single-line file `data/.cron-health-status` is
# committed back to main on every transition. Picked over `actions/cache`
# + artifacts because:
# 1. The repo is already the source of truth for collector state.
# 2. Cache keys can race / evict; a committed file cannot.
# 3. One file vs. configuring artifact upload+download steps. K2/K3.
# The file holds exactly one of: ok | stale | error (no trailing newline).
on:
schedule:
- cron: "*/15 * * * *" # UTC, every 15 min
workflow_dispatch: {}
permissions:
contents: write # needed to commit the .cron-health-status flip-file
pull-requests: write
concurrency:
group: cron-freshness-check
cancel-in-progress: false
env:
BASE_URL: ${{ vars.TRENDINGREPO_URL || vars.STARSCREENER_URL || 'https://trendingrepo.com' }}
STATUS_FILE: data/.cron-health-status
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
- name: GET live health endpoints
id: probe
# Probe outputs:
# status: "ok" | "stale" | "error"
# message: short human description for the alert payload
run: |
set +e
# ?soft=1 — /api/health returns 503 when ANY source is stale by
# default, which trips this workflow on normal hourly drift. Soft
# mode returns 200 with status="stale" in the body; the parser
# below already inspects body.status, so we still alert correctly
# without false-positive page failures.
code=$(curl -sS -o /tmp/out -w "%{http_code}" \
"$BASE_URL/api/health?soft=1")
curl_rc=$?
set -e
echo "app health HTTP $code (curl_rc=$curl_rc)"
if command -v jq >/dev/null 2>&1 && jq . /tmp/out >/dev/null 2>&1; then
jq . /tmp/out | head -c 2000
else
head -c 2000 /tmp/out
fi
echo
if [ "$curl_rc" != "0" ] || [ "$code" != "200" ]; then
echo "status=error" >> "$GITHUB_OUTPUT"
echo "message=health probe failed (HTTP $code, curl_rc=$curl_rc)" >> "$GITHUB_OUTPUT"
exit 0
fi
if command -v jq >/dev/null 2>&1; then
body_status=$(jq -r '.status // "missing"' /tmp/out)
echo "body_status=$body_status"
if [ "$body_status" != "ok" ]; then
echo "status=stale" >> "$GITHUB_OUTPUT"
echo "message=health status='$body_status' (expected 'ok')" >> "$GITHUB_OUTPUT"
exit 0
fi
else
echo "jq unavailable; app health accepted by HTTP 200 only"
fi
set +e
worker_code=$(curl -sS -o /tmp/worker-health -w "%{http_code}" \
"$BASE_URL/api/worker/health")
worker_rc=$?
pulse_code=$(curl -sS -o /tmp/worker-pulse -w "%{http_code}" \
"$BASE_URL/api/worker/pulse")
pulse_rc=$?
set -e
echo "worker health HTTP $worker_code (curl_rc=$worker_rc)"
head -c 2000 /tmp/worker-health || true
echo
echo "worker pulse HTTP $pulse_code (curl_rc=$pulse_rc)"
head -c 2000 /tmp/worker-pulse || true
echo
if [ "$worker_rc" != "0" ] || [ "$worker_code" != "200" ]; then
echo "status=error" >> "$GITHUB_OUTPUT"
echo "message=worker health failed (HTTP $worker_code, curl_rc=$worker_rc)" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$pulse_rc" != "0" ] || [ "$pulse_code" != "200" ]; then
echo "status=error" >> "$GITHUB_OUTPUT"
echo "message=worker pulse failed (HTTP $pulse_code, curl_rc=$pulse_rc)" >> "$GITHUB_OUTPUT"
exit 0
fi
if command -v jq >/dev/null 2>&1; then
worker_ok=$(jq -r '.ok // false' /tmp/worker-health)
worker_red=$(jq -r '.summary.red // 0' /tmp/worker-health)
worker_missing=$(jq -r '.summary.missing // 0' /tmp/worker-health)
worker_amber=$(jq -r '.summary.amber // 0' /tmp/worker-health)
worker_degraded=$(jq -r '.summary.degradedPayload // 0' /tmp/worker-health)
worker_empty=$(jq -r '.summary.emptyPayload // 0' /tmp/worker-health)
pulse_ok=$(jq -r '.ok // false' /tmp/worker-pulse)
pulse_source=$(jq -r '.source // "missing"' /tmp/worker-pulse)
pulse_fresh=$(jq -r '.fresh // false' /tmp/worker-pulse)
pulse_stories=$(jq -r '.stories // 0' /tmp/worker-pulse)
if [ "$worker_ok" != "true" ] || [ "$worker_red" != "0" ] || [ "$worker_missing" != "0" ] || [ "$worker_amber" != "0" ] || [ "$worker_degraded" != "0" ] || [ "$worker_empty" != "0" ]; then
echo "status=stale" >> "$GITHUB_OUTPUT"
echo "message=worker health non-green (ok=$worker_ok red=$worker_red missing=$worker_missing amber=$worker_amber degraded=$worker_degraded empty=$worker_empty)" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$pulse_ok" != "true" ] || [ "$pulse_source" != "redis" ] || [ "$pulse_fresh" != "true" ] || [ "$pulse_stories" -le 0 ]; then
echo "status=stale" >> "$GITHUB_OUTPUT"
echo "message=worker pulse not redis-fresh (ok=$pulse_ok source=$pulse_source fresh=$pulse_fresh stories=$pulse_stories)" >> "$GITHUB_OUTPUT"
exit 0
fi
else
echo "::warning::jq unavailable; worker JSON was not inspected"
fi
echo "status=ok" >> "$GITHUB_OUTPUT"
echo "message=app health, worker health, and Redis-backed worker pulse ok" >> "$GITHUB_OUTPUT"
- name: Read previous status
id: previous
# Defaults to 'unknown' so the very first run after this workflow
# ships emits exactly one alert (unknown → whatever the current state is).
run: |
if [ -f "$STATUS_FILE" ]; then
prev=$(cat "$STATUS_FILE" | tr -d '[:space:]')
else
prev="unknown"
fi
[ -z "$prev" ] && prev="unknown"
echo "prev=$prev"
echo "prev=$prev" >> "$GITHUB_OUTPUT"
- name: Notify on state change
if: ${{ steps.previous.outputs.prev != steps.probe.outputs.status }}
env:
OPS_ALERT_WEBHOOK: ${{ secrets.OPS_ALERT_WEBHOOK }}
PREV: ${{ steps.previous.outputs.prev }}
NEXT: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.message }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Discord-compatible / Slack-compatible-enough single-field payload.
# No-op (with a warning) if the webhook secret isn't configured —
# the workflow still updates the status file so future transitions
# won't be skipped.
run: |
if [ -z "${OPS_ALERT_WEBHOOK:-}" ]; then
echo "::warning::OPS_ALERT_WEBHOOK secret not set — skipping notification (state $PREV -> $NEXT)"
exit 0
fi
# Minimal Discord-shaped payload. Slack + Mattermost also accept
# a top-level `content` field for plain-text webhooks.
msg="[trendingrepo cron] $PREV -> $NEXT — $DETAIL ($RUN_URL)"
# Escape backslashes + double-quotes for safe JSON embedding.
esc=$(printf '%s' "$msg" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
curl -sS -X POST -H "Content-Type: application/json" \
-d "{\"content\": $esc}" \
"$OPS_ALERT_WEBHOOK" || echo "::warning::webhook POST failed (non-fatal)"
- name: Write status file
if: ${{ steps.previous.outputs.prev != steps.probe.outputs.status }}
env:
NEXT: ${{ steps.probe.outputs.status }}
run: |
mkdir -p "$(dirname "$STATUS_FILE")"
printf '%s' "$NEXT" > "$STATUS_FILE"
- name: Persist status
if: ${{ steps.previous.outputs.prev != steps.probe.outputs.status }}
# Commit the flip-file so the *next* run sees the new state.
# Skipped (by the if: above) when state didn't change — avoids an
# empty-commit per cycle.
# Step-level timeout — git-commit-data invokes `gh pr merge --auto`
# which can block indefinitely waiting for required checks. Cap at
# 5 min so the rest of the job (which may run other workloads) isn't
# starved. Pattern source: #1529 (collect-twitter.yml).
timeout-minutes: 5
uses: ./.github/actions/git-commit-data
with:
gh-token: ${{ secrets.DATA_BOT_TOKEN }}
actor-email: "bot@trendingrepo.com"
message: "chore(ops): cron health ${{ steps.probe.outputs.status }}"
paths: |
data/.cron-health-status
- name: Fail loud on stale/error
# Keep the existing red-X behavior so anyone watching the workflow
# tab still sees the failure. Webhook is additive, not replacement.
if: ${{ steps.probe.outputs.status != 'ok' }}
run: |
echo "::error::health status is '${{ steps.probe.outputs.status }}': ${{ steps.probe.outputs.message }}"
exit 1