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>
This commit is contained in:
Paul O'Reilly
2026-04-17 20:06:24 +12:00
parent cde811fa0e
commit 5a1b4b14bc
4 changed files with 539 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent-
| `unreflected-logs` | Scan projects for unreflected session logs | Done | | `unreflected-logs` | Scan projects for unreflected session logs | Done |
| `validate-skill` | Validate SKILL.md files against known Claude Code restrictions | Done | | `validate-skill` | Validate SKILL.md files against known Claude Code restrictions | Done |
| `semver-ci` | Compute `MAJOR.MINOR.PATCH.BUILD` from commit-message tokens, maintain `VERSION.md`, and (on main) tag/push/create a Gitea release — see [`docs/semver-ci.md`](docs/semver-ci.md) and template at [`templates/gitea-workflow-version.yml`](templates/gitea-workflow-version.yml) | Done | | `semver-ci` | Compute `MAJOR.MINOR.PATCH.BUILD` from commit-message tokens, maintain `VERSION.md`, and (on main) tag/push/create a Gitea release — see [`docs/semver-ci.md`](docs/semver-ci.md) and template at [`templates/gitea-workflow-version.yml`](templates/gitea-workflow-version.yml) | Done |
| `wait-for-release` | Poll a Gitea repo's releases until a glob pattern matches or a timeout expires (default 600s) | Done |
### Engagement modes (claude-profile) ### Engagement modes (claude-profile)

171
scripts/wait-for-release Executable file
View File

@@ -0,0 +1,171 @@
#!/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

View File

@@ -0,0 +1,111 @@
# wait-for-release
## Purpose
Poll a Gitea repository's releases endpoint until a release whose `tag_name` matches a shell-style glob pattern is published, or a timeout elapses. Designed to run in the background of a Claude Code session while a CI pipeline produces the release.
## Usage
```
wait-for-release [OPTIONS] REPO PATTERN
```
### Arguments
| Argument | Description |
|----------|-------------|
| `REPO` | Gitea repo in `owner/name` form (e.g. `skynet/myapp`). |
| `PATTERN` | Shell-style glob matched against each release's `tag_name` (e.g. `v1.2.3.*`). **Quote it** to prevent the caller's shell from expanding it. |
### Flags
| Flag | Description |
|------|-------------|
| `-t`, `--token TOKEN` | Gitea access token. Fallback order: `$GITEA_TOKEN`, `$GITHUB_TOKEN`. Omitted → unauthenticated request. |
| `--timeout SECONDS` | Max total wait time (default: 600). `0` means poll once then give up. |
| `--interval SECONDS` | Polling interval (default: 5; min: 1). |
| `--server URL` | Gitea base URL. Fallback order: `$GITHUB_SERVER_URL`, `https://gitea.oreillyit.nz`. |
| `-v`, `--verbose` | Emit polling progress to stderr. |
| `-n`, `--dryrun` | Print resolved configuration and exit; make no API calls. |
| `-h`, `--help` | Show usage. |
## Behaviour
1. Validate inputs: REPO must be `owner/name`; `--timeout` is a non-negative integer; `--interval` is an integer ≥ 1.
2. Compute `API_URL = <server>/api/v1/repos/<owner>/<name>/releases?limit=50`.
3. Compute `DEADLINE = now + timeout`.
4. Loop:
1. `GET API_URL` with a 30-second per-request timeout. Add `Authorization: token <TOKEN>` when a token is available.
2. On HTTP 200: extract `tag_name` values from the JSON body (via `jq` when installed, otherwise a simple regex). For each tag, test `[[ "$tag" == $PATTERN ]]` (bash glob). On the first match, print the tag to stdout and exit 0.
3. On HTTP 401 or 403: print auth error to stderr, exit 3.
4. On HTTP 404: print not-found error to stderr, exit 3.
5. On any other HTTP code or network failure: keep retrying (log to stderr when `--verbose`).
5. Between polls, sleep for `--interval` seconds, capped so the sleep never overshoots the deadline.
6. When the deadline has passed without a match, print a timeout error to stderr and exit 1.
## Dryrun Behaviour
When `--dryrun` is passed, no HTTP requests are made. Output (to stdout):
```
[dryrun] Server: https://gitea.oreillyit.nz
[dryrun] Repo: skynet/myapp
[dryrun] Pattern: v1.2.3.*
[dryrun] Timeout: 600s
[dryrun] Interval: 5s
[dryrun] Auth: yes
[dryrun] API URL: https://gitea.oreillyit.nz/api/v1/repos/skynet/myapp/releases?limit=50
```
`Auth: yes` when a token was supplied or present in the environment; otherwise `Auth: no`.
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | A release matching PATTERN was found; its `tag_name` is on stdout. |
| 1 | Timeout elapsed with no match. |
| 2 | Usage error (missing / invalid arguments). |
| 3 | Terminal API error (401, 403, 404). |
## Edge Cases
| Case | Handling |
|------|----------|
| REPO missing the slash | Error, exit 2. |
| PATTERN unquoted by caller and expanded by their shell | Caller's responsibility; the spec requires quoting. |
| No token for a private repo | API returns 401/403 → exit 3. |
| Invalid token | HTTP 401 → exit 3. |
| Repo doesn't exist / hidden | HTTP 404 → exit 3. |
| Transient 5xx or network error | Retry each `--interval` seconds until timeout. |
| Multiple releases match PATTERN | The first match in API response order (most recent first) wins. |
| PATTERN matches an already-published release | Returns on the first poll. |
| `--timeout 0` | Exactly one poll, then timeout if no match. |
| `--interval 0` | Error, exit 2. |
## Examples
```bash
# Wait for any build of a specific semver
wait-for-release skynet/myapp 'v1.2.3.*'
# Exact build
wait-for-release skynet/myapp 'v1.2.3.45'
# Any v1.x release, 20-minute window
wait-for-release --timeout 1200 skynet/myapp 'v1.*'
# With explicit token for a private repo
wait-for-release -t "$MY_TOKEN" skynet/private 'v1.2.*'
# Dryrun preview
wait-for-release --dryrun skynet/myapp 'v1.*'
```
### In a Claude Code session
Invoke via the Bash tool with `run_in_background=true`. The session is notified when the script exits. On success stdout holds the matched tag; the exit code signals the outcome.
```bash
wait-for-release skynet/myapp "v${VERSION}.*"
```

256
tests/test-wait-for-release.sh Executable file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WAIT="$SCRIPT_DIR/scripts/wait-for-release"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass=0
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected: $expected"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_match() {
local desc="$1" pattern="$2" actual="$3"
if [[ "$actual" =~ $pattern ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " pattern: $pattern"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_exit() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" -eq "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected exit: $expected"
echo " actual exit: $actual"
((fail++)) || true
fi
}
assert_between() {
local desc="$1" lo="$2" hi="$3" val="$4"
if (( val >= lo && val <= hi )); then
echo -e "${GREEN}PASS${NC}: $desc (${val} in [${lo},${hi}])"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc (${val} not in [${lo},${hi}])"
((fail++)) || true
fi
}
# --- test env setup ---
tmp_root="$(mktemp -d)"
trap 'rm -rf "$tmp_root"' EXIT
MOCK_DIR="$tmp_root/bin"
mkdir -p "$MOCK_DIR"
# Mock curl: emits fixture body then "\n<code>", ignoring all flags except URL.
cat > "$MOCK_DIR/curl" <<'MOCK'
#!/usr/bin/env bash
url=""
while [[ $# -gt 0 ]]; do
case "$1" in
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
[[ -n "${MOCK_CURL_LOG:-}" ]] && echo "$url" >> "$MOCK_CURL_LOG"
[[ -n "${MOCK_CURL_FAIL:-}" ]] && exit 7
if [[ -n "${MOCK_CURL_BODY:-}" && -f "$MOCK_CURL_BODY" ]]; then
cat "$MOCK_CURL_BODY"
fi
printf '\n%s' "${MOCK_CURL_CODE:-200}"
MOCK
chmod +x "$MOCK_DIR/curl"
run_with_mock() {
PATH="$MOCK_DIR:$PATH" "$WAIT" "$@"
}
# Fixtures
FIX_MATCH="$tmp_root/fix_match.json"
cat > "$FIX_MATCH" <<'JSON'
[
{"id":10,"tag_name":"v1.2.3.45","name":"v1.2.3.45"},
{"id":9,"tag_name":"v1.2.2.40","name":"v1.2.2.40"},
{"id":8,"tag_name":"v1.1.0.30","name":"v1.1.0.30"}
]
JSON
FIX_NO_MATCH="$tmp_root/fix_no_match.json"
cat > "$FIX_NO_MATCH" <<'JSON'
[
{"id":1,"tag_name":"v0.1.0.3","name":"v0.1.0.3"}
]
JSON
FIX_EMPTY="$tmp_root/fix_empty.json"
echo "[]" > "$FIX_EMPTY"
echo "=== wait-for-release tests ==="
echo
# --- Help ---
out=$("$WAIT" --help 2>&1); rc=$?
assert_exit "help exits 0" 0 "$rc"
assert_match "help mentions REPO" "REPO" "$out"
assert_match "help mentions PATTERN" "PATTERN" "$out"
assert_match "help lists exit codes" "Exit codes" "$out"
# --- Usage errors ---
out=$("$WAIT" 2>&1); rc=$?
assert_exit "no args exits 2" 2 "$rc"
assert_match "no args error" "REPO is required" "$out"
out=$("$WAIT" owner/repo 2>&1); rc=$?
assert_exit "no pattern exits 2" 2 "$rc"
assert_match "no pattern error" "PATTERN is required" "$out"
out=$("$WAIT" noslash 'v*' 2>&1); rc=$?
assert_exit "REPO without slash exits 2" 2 "$rc"
assert_match "REPO slash error" "owner/name" "$out"
out=$("$WAIT" --timeout abc owner/repo 'v*' 2>&1); rc=$?
assert_exit "non-numeric timeout exits 2" 2 "$rc"
out=$("$WAIT" --interval 0 owner/repo 'v*' 2>&1); rc=$?
assert_exit "interval 0 exits 2" 2 "$rc"
out=$("$WAIT" --unknown-flag owner/repo 'v*' 2>&1); rc=$?
assert_exit "unknown flag exits 2" 2 "$rc"
# --- Dryrun ---
out=$("$WAIT" --dryrun myorg/myrepo 'v1.2.3.*')
rc=$?
assert_exit "dryrun exits 0" 0 "$rc"
assert_match "dryrun shows server" "Server: " "$out"
assert_match "dryrun shows repo" "Repo: myorg/myrepo" "$out"
assert_match "dryrun shows pattern" 'Pattern: v1\.2\.3\.\*' "$out"
assert_match "dryrun shows timeout default" "Timeout: 600s" "$out"
assert_match "dryrun shows interval default" "Interval: 5s" "$out"
assert_match "dryrun shows API URL" "api/v1/repos/myorg/myrepo/releases" "$out"
# Auth: no (no token anywhere)
out=$(env -u GITEA_TOKEN -u GITHUB_TOKEN "$WAIT" --dryrun myorg/myrepo 'v*')
assert_match "no token -> Auth: no" "Auth: no" "$out"
# Auth: yes via --token
out=$(env -u GITEA_TOKEN -u GITHUB_TOKEN "$WAIT" --dryrun -t tok123 myorg/myrepo 'v*')
assert_match "--token -> Auth: yes" "Auth: yes" "$out"
# Auth: yes via GITEA_TOKEN env
out=$(env -u GITHUB_TOKEN GITEA_TOKEN=xyz "$WAIT" --dryrun myorg/myrepo 'v*')
assert_match "GITEA_TOKEN env -> Auth: yes" "Auth: yes" "$out"
# --server override appears in URL
out=$("$WAIT" --dryrun --server https://example.test myorg/myrepo 'v*')
assert_match "custom --server in URL" "https://example\.test/api/v1/repos/myorg/myrepo/releases" "$out"
# --- Real match via mock curl ---
export MOCK_CURL_BODY="$FIX_MATCH"
export MOCK_CURL_CODE=200
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v1.2.3.*')
rc=$?
assert_exit "immediate match exits 0" 0 "$rc"
assert_eq "immediate match prints tag" "v1.2.3.45" "$out"
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v1.*')
assert_eq "wildcard v1.* matches newest" "v1.2.3.45" "$out"
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v1.2.2.40')
assert_eq "exact tag match" "v1.2.2.40" "$out"
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v1.1.?.30')
assert_eq "? wildcard matches" "v1.1.0.30" "$out"
# --- No match -> timeout (short) ---
export MOCK_CURL_BODY="$FIX_NO_MATCH"
start_t=$(date +%s)
out=$(run_with_mock --timeout 2 --interval 1 test/repo 'v9.*' 2>&1); rc=$?
end_t=$(date +%s)
elapsed=$(( end_t - start_t ))
assert_exit "no match exits 1" 1 "$rc"
assert_match "no match: timeout error" "timeout after 2s" "$out"
assert_between "timeout elapsed within tolerance" 2 4 "$elapsed"
# --- Empty releases list -> timeout ---
export MOCK_CURL_BODY="$FIX_EMPTY"
out=$(run_with_mock --timeout 1 --interval 1 test/repo 'v1.*' 2>&1); rc=$?
assert_exit "empty list exits 1 (timeout)" 1 "$rc"
# --- 401 -> exit 3 ---
export MOCK_CURL_BODY="$FIX_MATCH"
export MOCK_CURL_CODE=401
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v*' 2>&1); rc=$?
assert_exit "401 exits 3" 3 "$rc"
assert_match "401 error message" "authentication failed" "$out"
# --- 403 -> exit 3 ---
export MOCK_CURL_CODE=403
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v*' 2>&1); rc=$?
assert_exit "403 exits 3" 3 "$rc"
# --- 404 -> exit 3 ---
export MOCK_CURL_CODE=404
out=$(run_with_mock --timeout 5 --interval 1 test/repo 'v*' 2>&1); rc=$?
assert_exit "404 exits 3" 3 "$rc"
assert_match "404 error message" "not found" "$out"
# --- Transient 500 -> retry until timeout ---
export MOCK_CURL_CODE=500
out=$(run_with_mock --timeout 2 --interval 1 test/repo 'v*' 2>&1); rc=$?
assert_exit "persistent 500 exits 1 (timeout)" 1 "$rc"
# --- Network error (curl fails) -> retry until timeout ---
export MOCK_CURL_FAIL=1
unset MOCK_CURL_CODE
out=$(run_with_mock --timeout 2 --interval 1 test/repo 'v*' 2>&1); rc=$?
assert_exit "network error exits 1 (timeout)" 1 "$rc"
unset MOCK_CURL_FAIL
# --- Verbose emits polling info to stderr ---
export MOCK_CURL_BODY="$FIX_NO_MATCH"
export MOCK_CURL_CODE=200
stderr_out=$(run_with_mock --verbose --timeout 1 --interval 1 test/repo 'v99.*' 2>&1 >/dev/null || true)
assert_match "verbose mentions poll" "poll " "$stderr_out"
# --- Polling still succeeds when --token is provided ---
export MOCK_CURL_BODY="$FIX_MATCH"
export MOCK_CURL_CODE=200
out=$(run_with_mock --token secret123 --timeout 5 --interval 1 test/repo 'v1.2.3.*')
assert_eq "polling with token still matches" "v1.2.3.45" "$out"
# --- Summary ---
echo
total=$(( pass + fail ))
echo -e "Results: ${GREEN}${pass}${NC}/${total} passed"
if (( fail > 0 )); then
echo -e "${RED}${fail} test(s) failed${NC}"
exit 1
fi