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>
172 lines
5.7 KiB
Bash
Executable File
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
|