#!/usr/bin/env bash set -uo pipefail DEFAULT_SERVER="https://gitea.oreillyit.nz" DEFAULT_TIMEOUT=300 DEFAULT_INTERVAL=15 DEFAULT_WORKFLOW="version" usage() { cat <<'EOF' Usage: wait-for-version [OPTIONS] REPO Poll a Gitea repo's Actions runs until the CI workflow for HEAD of main completes successfully, then print the latest release tag. Arguments: REPO owner/name (e.g. skynet/hugo-accelerator) Options: -t, --token TOKEN Gitea access token (else $GITEA_TOKEN / $GITHUB_TOKEN) --workflow NAME Workflow name to wait for (default: version) --timeout SECONDS Max total wait time (default: 300; 0 = poll once) --interval SECONDS Polling interval (default: 15; 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 CI succeeded; latest release tag printed to stdout 1 timeout expired, CI failed, or no release tag found 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:-}}" workflow="$DEFAULT_WORKFLOW" verbose=false dryrun=false repo="" 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 ;; --workflow) [[ $# -lt 2 ]] && { echo "Error: --workflow requires a value" >&2; exit 2; } workflow="$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" 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; } [[ "$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_base="${server%/}/api/v1/repos/${repo}" if $dryrun; then echo "[dryrun] Server: ${server}" echo "[dryrun] Repo: ${repo}" echo "[dryrun] Workflow: ${workflow}" 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] Actions API: ${api_base}/actions/runs" exit 0 fi curl_args=(-sS -m 30 -w $'\n%{http_code}') [[ -n "$token" ]] && curl_args+=(-H "Authorization: token ${token}") sha_response="$(curl "${curl_args[@]}" "${api_base}/branches/main" 2>/dev/null)" sha_rc=$? sha_http="${sha_response##*$'\n'}" sha_body="${sha_response%$'\n'*}" if (( sha_rc != 0 )); then echo "Error: curl failed resolving HEAD SHA for ${repo} (rc=${sha_rc})" >&2 exit 3 fi case "$sha_http" in 200) sha="$(printf '%s' "$sha_body" | jq -r '.commit.id // empty' 2>/dev/null)" [[ -z "$sha" ]] && { echo "Error: could not extract HEAD SHA from branch response" >&2; exit 3; } ;; 401|403) echo "Error: Gitea API authentication failed (HTTP ${sha_http}) for ${repo}" >&2; exit 3 ;; 404) echo "Error: repo not found: ${repo} (HTTP 404)" >&2; exit 3 ;; *) echo "Error: unexpected HTTP ${sha_http} resolving HEAD SHA for ${repo}" >&2; exit 3 ;; esac echo "Waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}..." >&2 start=$(date +%s) deadline=$(( start + timeout )) poll=0 while true; do poll=$(( poll + 1 )) runs_response="$(curl "${curl_args[@]}" \ "${api_base}/actions/runs?head_sha=${sha}&limit=50" 2>/dev/null)" runs_rc=$? if (( runs_rc != 0 )); then $verbose && echo "wait-for-version: poll ${poll}: curl error (rc=${runs_rc})" >&2 else http_code="${runs_response##*$'\n'}" body="${runs_response%$'\n'*}" case "$http_code" in 200) status="$(printf '%s' "$body" | jq -r \ --arg w "$workflow" \ '[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .status // "pending"' \ 2>/dev/null)" conclusion="$(printf '%s' "$body" | jq -r \ --arg w "$workflow" \ '[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .conclusion // ""' \ 2>/dev/null)" if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" ]]; then # Resolve tag by SHA match — avoids races with release creation # and works regardless of whether the repo uses Gitea releases. # When multiple tags share the SHA (e.g. v0.0.8 and v0.0.8.14), # prefer the 3-component semver root (fewest dot-components). tag_response="$(curl "${curl_args[@]}" "${api_base}/tags?limit=50" 2>/dev/null)" tag_http="${tag_response##*$'\n'}" tag_body="${tag_response%$'\n'*}" if [[ "$tag_http" != "200" ]]; then echo "Error: could not fetch tags for ${repo} (HTTP ${tag_http})" >&2 exit 1 fi tag="$(printf '%s' "$tag_body" | jq -r --arg sha "$sha" \ '[.[] | select(.commit.sha == $sha)] | sort_by(.name | ltrimstr("v") | split(".") | length) | first | .name // empty' 2>/dev/null)" if [[ -n "$tag" ]]; then $verbose && echo "wait-for-version: poll ${poll}: success, tag=${tag}" >&2 echo "$tag" exit 0 else echo "Error: no tag found for ${repo} @ ${sha:0:8}" >&2 exit 1 fi else echo "Error: CI failed for ${repo} @ ${sha:0:8} (conclusion: ${conclusion})" >&2 exit 1 fi fi $verbose && echo "wait-for-version: poll ${poll}: status=${status:-pending}, retrying" >&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-version: 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-version: poll ${poll}: sleeping ${sleep_time}s (${remaining}s remaining)" >&2 sleep "$sleep_time" done echo "Error: timeout after ${timeout}s waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}" >&2 exit 1