Skip to content

Commit 3386733

Browse files
DTTerastarorca-ide
andauthored
ci: probe by message text, not HTTP status; check iTunes first (#45)
Two fixes addressing the May 2026 false positive where the probe latched onto v2-12-5 because it didn't return the deprecation marker. v2-12-5 was actually returning 500 "database is unreachable" — a different kind of broken — but the probe's predicate ("not deprecated → live") couldn't tell the difference. Discriminator changed from HTTP status to message text: - "deprecated" → deprecated (1) - "session not found" / "invalid" / "expired" → healthy (0) - anything else → broken/transient (2) Liftoff's tRPC layer wraps ALL errors as httpStatus 500, so HTTP status is no signal at all. The healthy v2-13-10 host returns "Session not found" with httpStatus 500 — bit-for-bit identical envelope shape as the broken v2-12-5 host returning "database is unreachable". Discovery order also changed: 1. Probe the current host. If healthy, no-op. If broken (500/transient), warn and bail without bumping — try again tomorrow. 2. Ask the App Store via iTunes lookup what the current iOS app version is. Host names mirror the app version (2.13.10 → v2-13-10), so the iOS version IS the canonical answer to "what host should we use". If iTunes lookup succeeds and the derived host is healthy, use it. 3. Fall back to a sweep with a wider candidate window: same minor patch+1..15, minor+1 0..15, minor+2 0..5, major+1 0..2. Pick the HIGHEST healthy version found, not the first — earlier-version hosts can linger after a rotation, but we want the current one. The current candidate window (patch+1..3, minor+1..3, minor+2..1, major+1) would never have found v2-13-10 from a v2-12-3 starting point even with the new predicate, so the sweep widening is part of the fix. Verified locally against known states: v2-13-{9,10}=healthy, v2-13-5=deprecated, v2-12-5=broken/transient, v2-99-99=broken. Co-authored-by: Orca <[email protected]>
1 parent d82f920 commit 3386733

1 file changed

Lines changed: 94 additions & 29 deletions

File tree

.github/workflows/api-host-probe.yml

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,60 +28,125 @@ jobs:
2828
[ -n "$current" ] || { echo "::error::could not extract defaultAPIBase"; exit 1; }
2929
echo "current=$current"
3030
31-
# 0 = live, 1 = deprecated, 2 = no/failed response
31+
# probe(host) returns:
32+
# 0 = healthy: backend rejects our deliberately-invalid token with a
33+
# token-validation message (e.g. "Session not found", "invalid",
34+
# "expired", "unauthorized"). That's what a working host says.
35+
# 1 = deprecated: response body contains "deprecated"
36+
# 2 = broken/transient: anything else (500 "database is unreachable",
37+
# no response, network error, surprise message)
38+
# The earlier version of this probe used HTTP status as the
39+
# discriminator and got fooled in May 2026: Liftoff's tRPC layer wraps
40+
# ALL errors as httpStatus 500, so 500 doesn't mean "broken". The real
41+
# signal is the message text, and a broken backend (v2-12-5 was
42+
# returning "database is unreachable") is in the same status class as
43+
# a healthy one rejecting bad creds.
3244
probe() {
3345
local body
3446
body=$(curl -sS -m 10 "${1}/api/trpc/user.refreshToken?batch=1&input=%7B%220%22%3A%7B%22json%22%3A%22invalid%22%7D%7D" 2>/dev/null) || return 2
3547
[ -n "$body" ] || return 2
36-
echo "$body" | grep -qi "server is deprecated" && return 1
37-
return 0
48+
if echo "$body" | grep -qi "deprecated"; then
49+
return 1
50+
fi
51+
if echo "$body" | grep -qiE "session not found|invalid|expired|unauthor"; then
52+
return 0
53+
fi
54+
return 2
3855
}
3956
57+
# Probe the current host first; bail early if it's still healthy.
4058
set +e
4159
probe "$current"
4260
rc=$?
4361
set -e
4462
if [ $rc -eq 0 ]; then
45-
echo "no-op: $current still live"
63+
echo "no-op: $current still healthy"
4664
exit 0
4765
fi
4866
if [ $rc -eq 2 ]; then
49-
echo "::warning::current host $current did not respond — leaving as-is"
67+
echo "::warning::current host $current returned 500 / no response — leaving as-is, will retry tomorrow"
5068
exit 0
5169
fi
70+
echo "current host is deprecated, finding replacement"
5271
53-
echo "current host is deprecated, scanning candidates"
54-
ver=$(echo "$current" | grep -oE 'v[0-9]+-[0-9]+-[0-9]+')
55-
maj=${ver#v}; maj=${maj%%-*}
56-
rest=${ver#v*-}; min=${rest%%-*}; patch=${rest#*-}
57-
72+
# Primary discovery: ask the App Store what the current iOS app
73+
# version is. The host name mirrors the app version exactly
74+
# (2.13.10 → v2-13-10). If the iTunes lookup succeeds and the
75+
# derived host is healthy, use it.
5876
live=""
5977
lver=""
60-
for spec in \
61-
"$maj $min $((patch+1))" "$maj $min $((patch+2))" "$maj $min $((patch+3))" \
62-
"$maj $((min+1)) 0" "$maj $((min+1)) 1" "$maj $((min+1)) 2" "$maj $((min+1)) 3" \
63-
"$maj $((min+2)) 0" "$maj $((min+2)) 1" "$((maj+1)) 0 0"; do
64-
read M m p <<< "$spec"
65-
host="https://v${M}-${m}-${p}.api.getgymbros.com"
66-
set +e
67-
probe "$host"
68-
prc=$?
69-
set -e
70-
if [ $prc -eq 0 ]; then
71-
live="$host"
72-
lver="v${M}-${m}-${p}"
73-
break
78+
if itunes=$(curl -sS -m 10 'https://itunes.apple.com/lookup?id=6448081563&country=us' 2>/dev/null) && [ -n "$itunes" ]; then
79+
ios_ver=$(echo "$itunes" | python3 -c 'import json,sys; d=json.load(sys.stdin); r=d["results"][0] if d["results"] else {}; print(r.get("version",""))')
80+
if [ -n "$ios_ver" ]; then
81+
ios_host_ver="v$(echo "$ios_ver" | tr . -)"
82+
ios_host="https://${ios_host_ver}.api.getgymbros.com"
83+
echo "iOS app version: $ios_ver → trying $ios_host"
84+
set +e
85+
probe "$ios_host"
86+
prc=$?
87+
set -e
88+
if [ $prc -eq 0 ]; then
89+
live="$ios_host"
90+
lver="$ios_host_ver"
91+
echo "iTunes-derived host is healthy"
92+
else
93+
echo "iTunes-derived host returned rc=$prc; falling back to sweep"
94+
fi
7495
fi
75-
done
96+
fi
7697
98+
# Fallback sweep. Same idea as before but a broader candidate window
99+
# (minor+1 patches 0..15, minor+2 0..5, major+1 0..2) and the new
100+
# health predicate. We pick the highest healthy host found, not the
101+
# first, to bias toward "the current production version" rather than
102+
# "an old version that's still up".
77103
if [ -z "$live" ]; then
78-
echo "::error::no live host found in candidate window (looked at patch+1..3, minor+1, minor+2, major+1)"
104+
ver=$(echo "$current" | grep -oE 'v[0-9]+-[0-9]+-[0-9]+')
105+
maj=${ver#v}; maj=${maj%%-*}
106+
rest=${ver#v*-}; min=${rest%%-*}; patch=${rest#*-}
107+
108+
# Build candidate list: same minor (patch+1..15), then minor+1 (0..15), minor+2 (0..5), major+1 (0..2)
109+
candidates=()
110+
for p in $(seq $((patch+1)) $((patch+15))); do candidates+=("$maj $min $p"); done
111+
for p in $(seq 0 15); do candidates+=("$maj $((min+1)) $p"); done
112+
for p in $(seq 0 5); do candidates+=("$maj $((min+2)) $p"); done
113+
for p in $(seq 0 2); do candidates+=("$((maj+1)) 0 $p"); done
114+
115+
best=""
116+
best_M=0; best_m=0; best_p=0
117+
for spec in "${candidates[@]}"; do
118+
read M m p <<< "$spec"
119+
host="https://v${M}-${m}-${p}.api.getgymbros.com"
120+
set +e
121+
probe "$host"
122+
prc=$?
123+
set -e
124+
if [ $prc -eq 0 ]; then
125+
# Track the highest healthy version (lex-compare M,m,p tuples).
126+
if [ $M -gt $best_M ] || \
127+
{ [ $M -eq $best_M ] && [ $m -gt $best_m ]; } || \
128+
{ [ $M -eq $best_M ] && [ $m -eq $best_m ] && [ $p -gt $best_p ]; }; then
129+
best="$host"
130+
lver="v${M}-${m}-${p}"
131+
best_M=$M; best_m=$m; best_p=$p
132+
fi
133+
fi
134+
done
135+
live="$best"
136+
fi
137+
138+
if [ -z "$live" ]; then
139+
echo "::error::no healthy host found via iTunes lookup or candidate sweep"
79140
exit 1
80141
fi
81142
82-
echo "found live host: $live"
143+
echo "live host: $live ($lver)"
83144
84-
# Skip if a PR for this version already exists
145+
# Skip if the file is already on this version, or a PR for it is open.
146+
if [ "$live" = "$current" ]; then
147+
echo "default already matches ${lver}, no bump needed"
148+
exit 0
149+
fi
85150
if gh pr list --search "auto/bump-api-host-${lver} in:head state:open" --json number --jq 'length' | grep -qv '^0$'; then
86151
echo "PR already open for ${lver}; skipping"
87152
exit 0
@@ -100,4 +165,4 @@ jobs:
100165
101166
gh pr create --base main \
102167
--title "fix(auth): bump default API host to ${lver}" \
103-
--body "Automated probe found \`$current\` returns \"server is deprecated\". \`$live\` is currently live. Triggered by \`.github/workflows/api-host-probe.yml\`. Verify with \`LIFTOFF_API_BASE=$live liftoff-export auth refresh\` before merging."
168+
--body "Automated probe found \`$current\` returns \"server is deprecated\". \`$live\` is healthy (returns 4xx for an invalid-token probe, the expected response from a working backend). Triggered by \`.github/workflows/api-host-probe.yml\`. Verify with \`LIFTOFF_API_BASE=$live liftoff-export auth refresh\` before merging."

0 commit comments

Comments
 (0)