Skip to content

Commit fff833b

Browse files
authored
fix(ci): handle GitHub release API failures safely (#49)
1 parent 62b99b9 commit fff833b

8 files changed

Lines changed: 270 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ jobs:
6666
shell: bash
6767
run: scripts/test-read-current-local-artifact.sh
6868

69+
- name: Test GitHub API retry and 404 handling
70+
shell: bash
71+
run: scripts/test-github-api.sh
72+
6973
- name: Test release probe fixture
7074
shell: bash
7175
run: scripts/test-probe-release.sh

.github/workflows/mirror.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ jobs:
368368
shell: bash
369369
run: |
370370
set -euo pipefail
371+
source scripts/github-api.sh
371372
release_assets=(
372373
assets/status.png
373374
release-manifest.json
@@ -474,7 +475,8 @@ jobs:
474475
create_args+=(--latest)
475476
fi
476477
477-
if existing_assets_json="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets' 2>/dev/null)"; then
478+
release_lookup_status=0
479+
if existing_assets_json="$(github_release_assets_json_allow_404 "$RELEASE_TAG")"; then
478480
desired_asset_names=()
479481
for asset in "${release_assets[@]}"; do
480482
desired_asset_names+=("$(basename "$asset")")
@@ -560,6 +562,11 @@ jobs:
560562
echo "Release assets were reconciled without bulk clobbering existing downloads."
561563
fi
562564
else
565+
release_lookup_status=$?
566+
if [[ "$release_lookup_status" -ne "$GITHUB_API_NOT_FOUND_STATUS" ]]; then
567+
echo "Unable to determine whether GitHub Release $RELEASE_TAG exists; refusing to create it." >&2
568+
exit "$release_lookup_status"
569+
fi
563570
if [[ "${#required_existing_asset_names[@]}" -gt 0 ]]; then
564571
echo "Release $RELEASE_TAG does not exist, but release-manifest.json depends on preserved assets that can only be reused from an existing Release:" >&2
565572
printf ' %s\n' "${required_existing_asset_names[@]}" >&2

scripts/emergency-publish-release.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/github-api.sh"
5+
46
tag="${1:?release tag is required}"
57
title="${2:?release title is required}"
68
notes_file="${3:?release notes file is required}"
@@ -97,7 +99,8 @@ lowercase() {
9799
tr '[:upper:]' '[:lower:]' <<<"$1"
98100
}
99101

100-
if existing_assets="$(gh release view "$tag" --json assets --jq '.assets' 2>/dev/null)"; then
102+
release_lookup_status=0
103+
if existing_assets="$(github_release_assets_json_allow_404 "$tag")"; then
101104
existing_names_file="$(mktemp)"
102105
jq -r '.[].name' <<<"$existing_assets" | sort > "$existing_names_file"
103106
unexpected_existing_assets="$(comm -13 "$seen_names_file" "$existing_names_file")"
@@ -156,6 +159,11 @@ if existing_assets="$(gh release view "$tag" --json assets --jq '.assets' 2>/dev
156159
gh release upload "$tag" "$asset"
157160
done
158161
else
162+
release_lookup_status=$?
163+
if [[ "$release_lookup_status" -ne "$GITHUB_API_NOT_FOUND_STATUS" ]]; then
164+
echo "Unable to determine whether GitHub prerelease $tag exists; refusing to create it." >&2
165+
exit "$release_lookup_status"
166+
fi
159167
gh release create "$tag" \
160168
--target "${GITHUB_SHA:-main}" \
161169
--title "$title" \

scripts/github-api.sh

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env bash
2+
3+
# Sourceable GitHub API helpers. A confirmed HTTP 404 is returned as a distinct
4+
# status so callers never confuse a transient API failure with a missing object.
5+
6+
GITHUB_API_NOT_FOUND_STATUS=44
7+
8+
github_api_error_is_transient() {
9+
local error_file="$1"
10+
11+
grep -Eqi \
12+
'HTTP (429|5[0-9][0-9])|timed out|timeout|connection (reset|refused)|temporary failure|unexpected EOF' \
13+
"$error_file"
14+
}
15+
16+
github_api_json_allow_404() {
17+
local attempts="${GITHUB_API_ATTEMPTS:-3}"
18+
local delay_seconds="${GITHUB_API_RETRY_DELAY_SECONDS:-2}"
19+
local attempt
20+
local error_file
21+
local output
22+
local status
23+
24+
if [[ ! "$attempts" =~ ^[1-9][0-9]*$ ]]; then
25+
echo "GITHUB_API_ATTEMPTS must be a positive integer, got '$attempts'." >&2
26+
return 2
27+
fi
28+
if [[ ! "$delay_seconds" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
29+
echo "GITHUB_API_RETRY_DELAY_SECONDS must be a non-negative number, got '$delay_seconds'." >&2
30+
return 2
31+
fi
32+
33+
error_file="$(mktemp)"
34+
for ((attempt = 1; attempt <= attempts; attempt++)); do
35+
: > "$error_file"
36+
if output="$(gh api "$@" 2>"$error_file")"; then
37+
rm -f "$error_file"
38+
printf '%s\n' "$output"
39+
return 0
40+
else
41+
status=$?
42+
fi
43+
44+
if grep -q 'HTTP 404' "$error_file"; then
45+
rm -f "$error_file"
46+
return "$GITHUB_API_NOT_FOUND_STATUS"
47+
fi
48+
49+
cat "$error_file" >&2
50+
if ((attempt < attempts)) && github_api_error_is_transient "$error_file"; then
51+
echo "GitHub API request failed transiently; retrying ($attempt/$attempts)." >&2
52+
sleep "$delay_seconds"
53+
continue
54+
fi
55+
56+
rm -f "$error_file"
57+
return "$status"
58+
done
59+
60+
rm -f "$error_file"
61+
return 1
62+
}
63+
64+
github_release_json_allow_404() {
65+
local tag="$1"
66+
67+
github_api_json_allow_404 "repos/{owner}/{repo}/releases/tags/$tag"
68+
}
69+
70+
github_release_assets_json_allow_404() {
71+
local release_json
72+
local status
73+
local tag="$1"
74+
75+
if release_json="$(github_release_json_allow_404 "$tag")"; then
76+
jq -c '.assets // []' <<<"$release_json"
77+
return 0
78+
else
79+
status=$?
80+
fi
81+
82+
return "$status"
83+
}

scripts/probe-release.sh

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/github-api.sh"
5+
46
product_id="9PLM9XGG6VKS"
57
package_identity="OpenAI.Codex"
68
architecture="x64"
@@ -353,32 +355,6 @@ asset_size() {
353355
jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .size' <<<"$assets_json" | head -n 1
354356
}
355357

356-
github_api_json_allow_404() {
357-
local err_file
358-
local output
359-
local status
360-
361-
err_file="$(mktemp)"
362-
if output="$(gh api "$@" 2>"$err_file")"; then
363-
rm -f "$err_file"
364-
printf '%s\n' "$output"
365-
return 0
366-
else
367-
# Capture gh's real exit status here: `$?` taken after the closing `fi` would
368-
# be the if-statement's status (0 when the condition is false and there is no
369-
# else), masking the failure.
370-
status=$?
371-
fi
372-
if grep -q 'HTTP 404' "$err_file"; then
373-
rm -f "$err_file"
374-
return 1
375-
fi
376-
377-
cat "$err_file" >&2
378-
rm -f "$err_file"
379-
return "$status"
380-
}
381-
382358
latest_release_tag() {
383359
local release_json
384360
local status
@@ -391,7 +367,7 @@ latest_release_tag() {
391367
# condition with no else), not the helper's — capture it in the else branch.
392368
status=$?
393369
fi
394-
if [[ "$status" -eq 1 ]]; then
370+
if [[ "$status" -eq "$GITHUB_API_NOT_FOUND_STATUS" ]]; then
395371
printf ''
396372
return 0
397373
fi
@@ -401,7 +377,19 @@ latest_release_tag() {
401377

402378
github_release_json() {
403379
local tag="$1"
404-
gh api "repos/{owner}/{repo}/releases/tags/$tag"
380+
local status
381+
382+
if github_release_json_allow_404 "$tag"; then
383+
return 0
384+
else
385+
status=$?
386+
fi
387+
if [[ "$status" -eq "$GITHUB_API_NOT_FOUND_STATUS" ]]; then
388+
echo "GitHub release '$tag' was not found." >&2
389+
return 1
390+
fi
391+
392+
return "$status"
405393
}
406394

407395
release_assets_json() {

scripts/test-beta-publish-release.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ cat > "$tmp_dir/bin/gh" <<'GH'
5959
set -euo pipefail
6060
6161
printf '%s\n' "$*" >> "${GH_MOCK_LOG:?}"
62+
if [[ "${1:-}" == "api" ]]; then
63+
if [[ "${GH_MOCK_API_ERROR:-}" == "503" ]]; then
64+
echo 'gh: HTTP 503' >&2
65+
exit 1
66+
fi
67+
if [[ ! -f "${GH_MOCK_STATE:?}" ]]; then
68+
echo 'gh: Not Found (HTTP 404)' >&2
69+
exit 1
70+
fi
71+
cat "$GH_MOCK_STATE"
72+
exit 0
73+
fi
6274
[[ "${1:-}" == "release" ]] || { echo "unexpected gh command: $*" >&2; exit 1; }
6375
operation="${2:-}"
6476
tag="${3:-}"
@@ -139,6 +151,8 @@ publish() {
139151
GH_REPO=Wangnov/codex-app-mirror \
140152
GH_MOCK_STATE="$tmp_dir/release-state.json" \
141153
GH_MOCK_LOG="$tmp_dir/gh.log" \
154+
GH_MOCK_API_ERROR="${GH_MOCK_API_ERROR:-}" \
155+
GITHUB_API_RETRY_DELAY_SECONDS=0 \
142156
GITHUB_SHA=fixture-sha \
143157
bash "$repo_root/scripts/emergency-publish-release.sh" \
144158
"$tag" \
@@ -149,6 +163,21 @@ publish() {
149163
"$tmp_dir/artifacts"
150164
}
151165

166+
# A transient lookup failure must never be treated as proof that the release is
167+
# absent, even when every retry is exhausted.
168+
set +e
169+
GH_MOCK_API_ERROR=503 publish >"$tmp_dir/503.log" 2>&1
170+
lookup_status=$?
171+
set -e
172+
if [[ "$lookup_status" -eq 0 ]] ||
173+
[[ -f "$tmp_dir/release-state.json" ]] ||
174+
grep -Fq "release create $tag" "$tmp_dir/gh.log"; then
175+
echo "Expected GitHub API 503 to fail closed before prerelease creation." >&2
176+
cat "$tmp_dir/503.log" >&2
177+
exit 1
178+
fi
179+
: > "$tmp_dir/gh.log"
180+
152181
(
153182
cd "$repo_root"
154183
publish > "$tmp_dir/first.log"

scripts/test-github-api.sh

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
tmp_dir="$(mktemp -d)"
6+
cleanup() {
7+
rm -rf "$tmp_dir"
8+
}
9+
trap cleanup EXIT
10+
11+
mkdir -p "$tmp_dir/bin"
12+
counter_file="$tmp_dir/counter"
13+
14+
cat > "$tmp_dir/bin/gh" <<'GH'
15+
#!/usr/bin/env bash
16+
set -euo pipefail
17+
18+
[[ "${1:-}" == "api" ]] || { echo "unexpected gh invocation: $*" >&2; exit 2; }
19+
count="$(cat "${TEST_GH_COUNTER:?}")"
20+
count=$((count + 1))
21+
printf '%s' "$count" > "$TEST_GH_COUNTER"
22+
23+
case "${TEST_GH_MODE:?}" in
24+
recover)
25+
if ((count < 3)); then
26+
echo 'gh: HTTP 503' >&2
27+
exit 1
28+
fi
29+
printf '{"tag_name":"codex-app-1.2.3"}\n'
30+
;;
31+
persistent-503)
32+
echo 'gh: HTTP 503' >&2
33+
exit 1
34+
;;
35+
not-found)
36+
echo 'gh: Not Found (HTTP 404)' >&2
37+
exit 1
38+
;;
39+
release-assets)
40+
printf '{"assets":[{"name":"asset.bin","size":3,"digest":"sha256:abc"}]}\n'
41+
;;
42+
*)
43+
echo "unexpected TEST_GH_MODE: $TEST_GH_MODE" >&2
44+
exit 2
45+
;;
46+
esac
47+
GH
48+
chmod +x "$tmp_dir/bin/gh"
49+
50+
source "$repo_root/scripts/github-api.sh"
51+
52+
run_with_mode() {
53+
local mode="$1"
54+
shift
55+
56+
printf '0' > "$counter_file"
57+
PATH="$tmp_dir/bin:$PATH" \
58+
TEST_GH_MODE="$mode" \
59+
TEST_GH_COUNTER="$counter_file" \
60+
GITHUB_API_ATTEMPTS=3 \
61+
GITHUB_API_RETRY_DELAY_SECONDS=0 \
62+
"$@"
63+
}
64+
65+
output="$(run_with_mode recover github_api_json_allow_404 'repos/{owner}/{repo}/releases/latest' 2>"$tmp_dir/recover.err")"
66+
[[ "$(jq -r .tag_name <<<"$output")" == "codex-app-1.2.3" ]]
67+
[[ "$(cat "$counter_file")" == "3" ]]
68+
[[ "$(grep -c 'retrying' "$tmp_dir/recover.err")" == "2" ]]
69+
70+
set +e
71+
run_with_mode persistent-503 github_api_json_allow_404 'repos/{owner}/{repo}/releases/latest' >"$tmp_dir/503.out" 2>"$tmp_dir/503.err"
72+
status=$?
73+
set -e
74+
[[ "$status" -ne 0 && "$status" -ne "$GITHUB_API_NOT_FOUND_STATUS" ]]
75+
[[ "$(cat "$counter_file")" == "3" ]]
76+
[[ ! -s "$tmp_dir/503.out" ]]
77+
grep -Fq 'gh: HTTP 503' "$tmp_dir/503.err"
78+
79+
set +e
80+
run_with_mode not-found github_api_json_allow_404 'repos/{owner}/{repo}/releases/latest' >"$tmp_dir/404.out" 2>"$tmp_dir/404.err"
81+
status=$?
82+
set -e
83+
[[ "$status" -eq "$GITHUB_API_NOT_FOUND_STATUS" ]]
84+
[[ "$(cat "$counter_file")" == "1" ]]
85+
[[ ! -s "$tmp_dir/404.out" ]]
86+
87+
assets="$(run_with_mode release-assets github_release_assets_json_allow_404 codex-app-1.2.3)"
88+
[[ "$(jq -r '.[0].name' <<<"$assets")" == "asset.bin" ]]
89+
90+
echo "GitHub API retry and 404 handling fixture PASS"

0 commit comments

Comments
 (0)