Skip to content

Commit 1fc33ec

Browse files
mikhail-dclclaude
andcommitted
ci: retry the Actions API, and stop a dead listing reading as "no build"
Review finding on #9694: absorbing API failures fixed the 90-minute wait and broke the two paths that do not get a second poll. `wait-minutes: 0` decides on one answer. `visual-regression.yml` takes that path on every /visual-tests, and create-release-branch.yml's dispatched gate takes it on every release cut — so one 5xx reported a build that is very likely fine as missing, and reds a required check on a brand-new release PR. `gh` has no retry of its own; three attempts five seconds apart cover it. The same absorption also gave `none` back a second meaning one level up: an empty listing produced TOTAL=0 and no candidate loop, so "no run exists for this SHA" and "every listing call failed" arrived at the same message — the one telling the operator to label a fully-built commit `force-build`. The comment above it asserted that could not happen. Two flags, not one, because the scenario that reaches this is rate-limit exhaustion, and this loop is what spends the budget: it starts partway through a wait, when a listing has already succeeded. `LISTED` alone would still print the force-build message. `LISTED_NOW` says whether the last poll answered, `LISTED` whether any poll ever did, and between them the timeout names which of the three happened. The jobs call keeps one flag: its failure lands on `pending`, whose message asks rather than asserts and never points at `force-build`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1b26e12 commit 1fc33ec

1 file changed

Lines changed: 83 additions & 11 deletions

File tree

.github/actions/resolve-explorer-build/action.yml

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,34 @@ runs:
145145
exit 0
146146
fi
147147
148+
# gh has no built-in retry, and both callers below are decisive on a
149+
# single answer: the wait-minutes: 0 path has no second poll, and a
150+
# listing that fails every poll would otherwise read as "no build
151+
# exists". stderr is dropped because a 90-minute wait polls ~90 times
152+
# and every failure that survives the retry is reported, with an
153+
# action, by the messages below.
154+
gh_api_retry() {
155+
local out attempt
156+
for attempt in 1 2 3; do
157+
if out=$(gh api "$@" 2>/dev/null); then printf '%s' "$out"; return 0; fi
158+
# Not after the last attempt: the callers below act on the answer
159+
# immediately, so that sleep would only delay a red check.
160+
if [ "$attempt" -lt 3 ]; then sleep 5; fi
161+
done
162+
return 1
163+
}
164+
165+
# A listing that fails leaves the same candidate set as a commit with
166+
# no build — TOTAL=0, nothing examined, STATE=none — so without a
167+
# record of the listing itself the messages below cannot tell an
168+
# operator which of the two happened, and only one of them is fixed by
169+
# `force-build`. LISTED is "did any poll's listing ever answer";
170+
# LISTED_NOW, set per poll further down, is "did the last one". Both
171+
# are needed: this loop spends ~360 requests/hour of a 1,000/hour
172+
# budget it shares with build-unitycloud and the review bots, so the
173+
# realistic outage — rate-limit exhaustion — begins partway through a
174+
# 90-minute wait, and by then LISTED has long since flipped.
175+
LISTED=0
148176
DEADLINE=$(( SECONDS + WAIT_MINUTES * 60 ))
149177
while :; do
150178
# Scoped to the build workflow file: a repo-wide head_sha listing gets
@@ -156,10 +184,18 @@ runs:
156184
#
157185
# Absorbed rather than left to `set -e`: one 5xx or rate-limit blip
158186
# would otherwise end a 90-minute wait with a red required check on a
159-
# release PR that has nothing wrong with it. An empty list reads as
160-
# "nothing found this poll" and the loop asks again a minute later.
161-
RUNS=$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/build-unitycloud.yml/runs?head_sha=${HEAD_SHA}&per_page=50" \
162-
--jq '[.workflow_runs[] | select(.event == "pull_request" or .event == "push")]') || RUNS=''
187+
# release PR that has nothing wrong with it. Only an outage that
188+
# survives all three attempts lands in the else arm; an empty list
189+
# reads as "nothing found this poll" and the loop asks again a
190+
# minute later.
191+
LISTED_NOW=0
192+
if RUNS=$(gh_api_retry "repos/${GITHUB_REPOSITORY}/actions/workflows/build-unitycloud.yml/runs?head_sha=${HEAD_SHA}&per_page=50" \
193+
--jq '[.workflow_runs[] | select(.event == "pull_request" or .event == "push")]'); then
194+
LISTED=1
195+
LISTED_NOW=1
196+
else
197+
RUNS=''
198+
fi
163199
if [ -z "$RUNS" ]; then RUNS='[]'; fi
164200
TOTAL=$(jq 'length' <<< "$RUNS")
165201
LIMIT=$TOTAL
@@ -173,7 +209,7 @@ runs:
173209
# Wait on the build legs, not on the run: they upload the zips, and
174210
# Build Gate and everything else downstream only conclude later.
175211
# One call per candidate, filtered per leg locally.
176-
JOBS=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/$(jq -r '.id' <<< "$CAND")/jobs?per_page=50" \
212+
JOBS=$(gh_api_retry "repos/${GITHUB_REPOSITORY}/actions/runs/$(jq -r '.id' <<< "$CAND")/jobs?per_page=50" \
177213
--jq '[.jobs[] | {name, status, conclusion}]') || JOBS=''
178214
if [ -z "$JOBS" ]; then
179215
# Same blip tolerance as the listing above, but an unreachable API
@@ -218,6 +254,18 @@ runs:
218254
if [ -n "$RUN_JSON" ]; then break; fi
219255
220256
if [ "$WAIT_MINUTES" -eq 0 ]; then
257+
# The consequential half of the same ambiguity: this path has no
258+
# second poll to correct itself, and it is the one
259+
# visual-regression.yml takes on every /visual-tests, and
260+
# in-world-tests.yml on every dispatch — which is how
261+
# create-release-branch.yml gates a release cut. Reporting an
262+
# unanswered API as a missing build sends the operator after a
263+
# build that is very likely fine. One poll, so LISTED and
264+
# LISTED_NOW are the same bit here.
265+
if [ "$LISTED" = "0" ]; then
266+
echo "::error::Could not list build-unitycloud.yml runs for ${HEAD_SHA:0:7} — the GitHub Actions API did not answer. Nothing was learned about the build, so this says nothing about whether one exists: re-run this job."
267+
exit 1
268+
fi
221269
echo "::error::No Unity Cloud Build of ${HEAD_SHA:0:7} with all of [${BUILD_LEGS}] green. Is the build still in progress, or did it fail?"
222270
exit 1
223271
fi
@@ -236,19 +284,43 @@ runs:
236284
fi
237285
if [ "$SECONDS" -ge "$DEADLINE" ]; then
238286
echo "::error::Gave up after ${WAIT_MINUTES}m waiting for a build of ${HEAD_SHA:0:7} with [${BUILD_LEGS}] green."
239-
# With the case above carved out, `none` is only reachable when no
287+
# With the cases above carved out, `none` means the candidate loop
288+
# never reached a verdict, which happens two ways: no
240289
# pull_request or push run of build-unitycloud.yml exists for the
241-
# SHA at all — a run that started and built nothing is `nomatrix`.
242-
# Labelling and pushing are both trigger types of that workflow, so
243-
# either queues the run this commit never got.
290+
# SHA at all — a run that started and built nothing is `nomatrix`
291+
# — or the last poll's listing went unanswered, leaving the same
292+
# empty set with nothing learned. Only the first is a build
293+
# problem: labelling and pushing are both trigger types of that
294+
# workflow, so either queues the run this commit never got,
295+
# whereas a `force-build` label does nothing whatsoever for an API
296+
# that is not replying.
244297
if [ "$STATE" = none ]; then
245-
echo "::error::No build was ever queued for this commit. Label the PR \`force-build\`, or push a change under Explorer/, then re-run this job."
298+
if [ "$LISTED_NOW" = "0" ]; then
299+
# The wait ended blind, so nothing here establishes that no
300+
# build exists. LISTED then separates an outage that lasted
301+
# the whole wait from one that began during it, because the
302+
# remedies differ: a spent rate-limit budget refills on its
303+
# own hour boundary and needs no page checked.
304+
if [ "$LISTED" = "0" ]; then
305+
echo "::error::The GitHub Actions API never answered a single run listing in ${WAIT_MINUTES}m, so no build was ever looked at. Check https://www.githubstatus.com, then re-run this job."
306+
else
307+
echo "::error::The GitHub Actions API stopped answering run listings partway through the wait, so whether a build exists is unknown — most likely this token's hourly request budget is spent. Re-run this job after the hour rolls over."
308+
fi
309+
else
310+
echo "::error::No build was ever queued for this commit. Label the PR \`force-build\`, or push a change under Explorer/, then re-run this job."
311+
fi
246312
else
247313
echo "::error::Re-run this job once the build is green."
248314
fi
249315
exit 1
250316
fi
251-
echo "Waiting for [${BUILD_LEGS}] on ${HEAD_SHA:0:7} — ${TOTAL} build run(s) found, newest ${LIMIT} examined, state: ${STATE}. $(( (DEADLINE - SECONDS) / 60 ))m left."
317+
# Without the note this line claims "0 build run(s) found" once a
318+
# minute for an hour and a half, which is the same thing the flags
319+
# above exist to stop asserting — and this trail is what makes an
320+
# outage diagnosable after the fact.
321+
LISTING_NOTE=''
322+
if [ "$LISTED_NOW" = "0" ]; then LISTING_NOTE=' (run listing unavailable this poll)'; fi
323+
echo "Waiting for [${BUILD_LEGS}] on ${HEAD_SHA:0:7} — ${TOTAL} build run(s) found, newest ${LIMIT} examined, state: ${STATE}${LISTING_NOTE}. $(( (DEADLINE - SECONDS) / 60 ))m left."
252324
sleep 60
253325
done
254326

0 commit comments

Comments
 (0)