diff --git a/README.md b/README.md index 2f35cd9..720c6a8 100644 --- a/README.md +++ b/README.md @@ -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 | | `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 | +| `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) diff --git a/scripts/wait-for-release b/scripts/wait-for-release new file mode 100755 index 0000000..4a7a163 --- /dev/null +++ b/scripts/wait-for-release @@ -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 diff --git a/specs/wait-for-release.spec.md b/specs/wait-for-release.spec.md new file mode 100644 index 0000000..774917c --- /dev/null +++ b/specs/wait-for-release.spec.md @@ -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 = /api/v1/repos///releases?limit=50`. +3. Compute `DEADLINE = now + timeout`. +4. Loop: + 1. `GET API_URL` with a 30-second per-request timeout. Add `Authorization: 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}.*" +``` diff --git a/tests/test-wait-for-release.sh b/tests/test-wait-for-release.sh new file mode 100755 index 0000000..c839762 --- /dev/null +++ b/tests/test-wait-for-release.sh @@ -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", 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