Files
small-scripts/scripts/wait-for-release
Paul O'Reilly 5a1b4b14bc Add wait-for-release: poll a Gitea repo's releases until a glob matches
Polls /api/v1/repos/<owner>/<repo>/releases and matches each tag_name
against a shell-style glob (e.g. v1.2.3.*). Default 600s timeout, 5s
interval, both overridable. Exits 0 with the matched tag on stdout,
1 on timeout, 2 on usage error, 3 on terminal API error (401/403/404).
Intended to run in the background of a Claude Code session while CI
produces the release.

Includes spec and 42-assertion test using a PATH-shadowed mock curl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:06:24 +12:00

172 lines
5.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -uo pipefail
DEFAULT_SERVER="https://gitea.oreillyit.nz"
DEFAULT_TIMEOUT=600
DEFAULT_INTERVAL=5
usage() {
cat <<'EOF'
Usage: wait-for-release [OPTIONS] REPO PATTERN
Poll a Gitea repo's releases until one matches PATTERN (shell-style glob)
or the timeout expires.
Arguments:
REPO owner/name (e.g. skynet/myapp)
PATTERN shell-style glob matched against each release's tag_name
(e.g. 'v1.2.3.*', 'v1.2.*', 'v1.*'). Quote it to prevent
your shell from expanding it.
Options:
-t, --token TOKEN Gitea access token (else $GITEA_TOKEN / $GITHUB_TOKEN)
--timeout SECONDS Max total wait time (default: 600; 0 = poll once)
--interval SECONDS Polling interval (default: 5; min: 1)
--server URL Gitea base URL (else $GITHUB_SERVER_URL, else
https://gitea.oreillyit.nz)
-v, --verbose Print polling progress to stderr
-n, --dryrun Show resolved configuration without making API calls
-h, --help Show this help
Exit codes:
0 matching release found (tag_name printed to stdout)
1 timeout expired with no match
2 usage error
3 terminal API error (401, 403, 404)
EOF
}
timeout=$DEFAULT_TIMEOUT
interval=$DEFAULT_INTERVAL
server="${GITHUB_SERVER_URL:-$DEFAULT_SERVER}"
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
verbose=false
dryrun=false
repo=""
pattern=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-n|--dryrun) dryrun=true; shift ;;
-v|--verbose) verbose=true; shift ;;
-t|--token)
[[ $# -lt 2 ]] && { echo "Error: --token requires a value" >&2; exit 2; }
token="$2"; shift 2 ;;
--timeout)
[[ $# -lt 2 ]] && { echo "Error: --timeout requires a value" >&2; exit 2; }
timeout="$2"; shift 2 ;;
--interval)
[[ $# -lt 2 ]] && { echo "Error: --interval requires a value" >&2; exit 2; }
interval="$2"; shift 2 ;;
--server)
[[ $# -lt 2 ]] && { echo "Error: --server requires a value" >&2; exit 2; }
server="$2"; shift 2 ;;
--) shift; break ;;
-*) echo "Error: Unknown option: $1" >&2; exit 2 ;;
*)
if [[ -z "$repo" ]]; then repo="$1"
elif [[ -z "$pattern" ]]; then pattern="$1"
else echo "Error: Unexpected argument: $1" >&2; exit 2
fi
shift ;;
esac
done
[[ -z "$repo" ]] && { echo "Error: REPO is required" >&2; echo >&2; usage >&2; exit 2; }
[[ -z "$pattern" ]] && { echo "Error: PATTERN is required" >&2; echo >&2; usage >&2; exit 2; }
[[ "$repo" != */* ]] && { echo "Error: REPO must be in 'owner/name' form (got: $repo)" >&2; exit 2; }
[[ ! "$timeout" =~ ^[0-9]+$ ]] && { echo "Error: --timeout must be a non-negative integer (got: $timeout)" >&2; exit 2; }
[[ ! "$interval" =~ ^[0-9]+$ ]] && { echo "Error: --interval must be a positive integer (got: $interval)" >&2; exit 2; }
(( interval < 1 )) && { echo "Error: --interval must be >= 1" >&2; exit 2; }
api_url="${server%/}/api/v1/repos/${repo}/releases?limit=50"
if $dryrun; then
echo "[dryrun] Server: ${server}"
echo "[dryrun] Repo: ${repo}"
echo "[dryrun] Pattern: ${pattern}"
echo "[dryrun] Timeout: ${timeout}s"
echo "[dryrun] Interval: ${interval}s"
if [[ -n "$token" ]]; then
echo "[dryrun] Auth: yes"
else
echo "[dryrun] Auth: no"
fi
echo "[dryrun] API URL: ${api_url}"
exit 0
fi
extract_tags() {
if command -v jq >/dev/null 2>&1; then
jq -r '.[]?.tag_name // empty' 2>/dev/null
else
grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
| sed -E 's/.*"([^"]+)"$/\1/'
fi
}
curl_cmd=(curl -sS -m 30 -w $'\n%{http_code}')
[[ -n "$token" ]] && curl_cmd+=(-H "Authorization: token ${token}")
start=$(date +%s)
deadline=$(( start + timeout ))
poll=0
while true; do
poll=$(( poll + 1 ))
response="$("${curl_cmd[@]}" "$api_url" 2>/dev/null)"
curl_rc=$?
if (( curl_rc != 0 )); then
$verbose && echo "wait-for-release: poll ${poll}: network/curl error (rc=${curl_rc})" >&2
else
http_code="${response##*$'\n'}"
body="${response%$'\n'*}"
case "$http_code" in
200)
match=""
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
# shellcheck disable=SC2053 # intentional glob match
if [[ "$tag" == $pattern ]]; then
match="$tag"
break
fi
done < <(printf '%s' "$body" | extract_tags)
if [[ -n "$match" ]]; then
$verbose && echo "wait-for-release: poll ${poll}: matched '${match}'" >&2
echo "$match"
exit 0
fi
$verbose && echo "wait-for-release: poll ${poll}: HTTP 200, no match yet" >&2
;;
401|403)
echo "Error: Gitea API authentication failed (HTTP ${http_code}) for ${repo}" >&2
exit 3 ;;
404)
echo "Error: repo not found: ${repo} (HTTP 404)" >&2
exit 3 ;;
*)
$verbose && echo "wait-for-release: poll ${poll}: HTTP ${http_code}, retrying" >&2
;;
esac
fi
now=$(date +%s)
if (( now >= deadline )); then
break
fi
remaining=$(( deadline - now ))
sleep_time=$interval
(( sleep_time > remaining )) && sleep_time=$remaining
$verbose && echo "wait-for-release: poll ${poll}: sleeping ${sleep_time}s (${remaining}s remaining)" >&2
sleep "$sleep_time"
done
echo "Error: timeout after ${timeout}s waiting for '${pattern}' in ${repo}" >&2
exit 1