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:
111
specs/wait-for-release.spec.md
Normal file
111
specs/wait-for-release.spec.md
Normal 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}.*"
|
||||
```
|
||||
Reference in New Issue
Block a user