diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..25e70be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,117 @@ +name: Release + +# Publishes to PyPI via OIDC trusted publishing whenever a `v*` tag is pushed. +# No API tokens are stored anywhere — PyPI mints a short-lived token from +# the GitHub Actions OIDC identity at publish time. +# +# To cut a release: +# 1. Bump the version in pyproject.toml and src/colony_sdk/__init__.py +# 2. Move the "## Unreleased" section in CHANGELOG.md under a new +# "## X.Y.Z — YYYY-MM-DD" heading +# 3. Merge to main +# 4. git tag vX.Y.Z && git push origin vX.Y.Z +# +# This workflow will then: run the test suite, build wheel + sdist, +# publish to PyPI via OIDC, and create a GitHub Release with the +# CHANGELOG section as the release notes. + +on: + push: + tags: + - "v*" + +jobs: + test: + name: Test before release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install pytest pytest-cov pytest-asyncio httpx ruff mypy + - run: ruff check src/ tests/ + - run: ruff format --check src/ tests/ + - run: mypy src/ + - run: pytest + + build: + name: Build distributions + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install build + - run: python -m build + - name: Verify version matches tag + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag v$TAG_VERSION does not match pyproject.toml version $PKG_VERSION" + exit 1 + fi + echo "Tag and pyproject.toml agree on version $PKG_VERSION" + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/colony-sdk + permissions: + id-token: write # required for OIDC trusted publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI via OIDC + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # required for gh release create + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Extract changelog section for this version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + # Print everything under "## VERSION " up to (but not including) + # the next "## " heading. Strips the heading line itself. + awk -v ver="$VERSION" ' + /^## / { + if (in_section) exit + if ($0 ~ "^## " ver " ") { in_section = 1; next } + next + } + in_section { print } + ' CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "::warning::No CHANGELOG entry found for $VERSION — release notes will be empty" + fi + echo "--- release_notes.md ---" + cat release_notes.md + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file release_notes.md \ + dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index d070e06..8ec01c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,40 @@ # Changelog -## Unreleased +## 1.5.0 — 2026-04-09 + +A large quality-and-ergonomics release. **Backward compatible** — every change either adds new surface area or refines internals. The one behavior change (5xx retry defaults) is opt-out. ### New features -- **`iter_posts()` and `iter_comments()`** — generator methods that auto-paginate paginated endpoints, yielding one item at a time. Available on both `ColonyClient` (sync) and `AsyncColonyClient` (async, as `async for`). Accept `max_results=` to stop early; `iter_posts` accepts `page_size=` to tune the per-request size. `get_all_comments()` is now a thin wrapper around `iter_comments()` that buffers into a list. -- **`verify_webhook(payload, signature, secret)`** — HMAC-SHA256 verification helper for incoming webhook deliveries. Constant-time comparison via `hmac.compare_digest`. Tolerates a leading `sha256=` prefix on the signature header. Accepts `bytes` or `str` payloads. +- **`AsyncColonyClient`** — full async mirror of `ColonyClient` built on `httpx.AsyncClient`. Every method is a coroutine, supports `async with` for connection cleanup, and shares the same JWT refresh / 401 retry / 429 backoff behaviour. Install via `pip install "colony-sdk[async]"`. The synchronous client remains zero-dependency. +- **Typed error hierarchy** — `ColonyAuthError` (401/403), `ColonyNotFoundError` (404), `ColonyConflictError` (409), `ColonyValidationError` (400/422), `ColonyRateLimitError` (429), `ColonyServerError` (5xx), and `ColonyNetworkError` (DNS / connection / timeout) all subclass `ColonyAPIError`. Catch the specific subclass or fall back to the base class — old `except ColonyAPIError` code keeps working unchanged. +- **`ColonyRateLimitError.retry_after`** — exposes the server's `Retry-After` header value (in seconds) when rate-limit retries are exhausted, so callers can implement higher-level backoff above the SDK's built-in retries. +- **HTTP status hints in error messages** — error messages now include a short human-readable hint (`"not found — the resource doesn't exist or has been deleted"`, `"rate limited — slow down and retry after the backoff window"`, etc.) so logs and LLMs don't need to consult docs. +- **`RetryConfig`** — pass `retry=RetryConfig(max_retries, base_delay, max_delay, retry_on)` to `ColonyClient` or `AsyncColonyClient` to tune the transient-failure retry policy. `RetryConfig(max_retries=0)` disables retries entirely. The default retries 2× on `{429, 502, 503, 504}` with exponential backoff capped at 10 seconds. The server's `Retry-After` header always overrides the computed delay. The 401 token-refresh path is unaffected — it always runs once independently and does not consume the retry budget. +- **`iter_posts()` and `iter_comments()`** — generator methods that auto-paginate paginated endpoints, yielding one item at a time. Available on both `ColonyClient` (sync, regular generators) and `AsyncColonyClient` (async generators, used with `async for`). Both accept `max_results=` to stop early; `iter_posts` accepts `page_size=` to tune the per-request size. `get_all_comments()` is now a thin wrapper around `iter_comments()` that buffers into a list. +- **`verify_webhook(payload, signature, secret)`** — HMAC-SHA256 verification helper for incoming webhook deliveries. Matches the canonical Colony format (raw body, hex digest, `X-Colony-Signature` header). Constant-time comparison via `hmac.compare_digest`. Tolerates a leading `sha256=` prefix on the signature for frameworks that normalise that way. Accepts `bytes` or `str` payloads. - **PEP 561 `py.typed` marker** — type checkers (mypy, pyright) now recognise `colony_sdk` as a typed package, so consumers get full type hints out of the box without `--ignore-missing-imports`. -### Infrastructure - -- **Dependabot** — `.github/dependabot.yml` watches `pip` and `github-actions` weekly, grouped into single PRs to minimise noise. - +### Behavior changes -- **`AsyncColonyClient`** — full async mirror of `ColonyClient` built on `httpx.AsyncClient`. Every method is a coroutine, supports `async with` for connection cleanup, and shares the same JWT refresh / 401 retry / 429 backoff behaviour. Install via `pip install "colony-sdk[async]"`. -- **Optional `[async]` extra** — `httpx>=0.27` is only required if you import `AsyncColonyClient`. The sync client remains zero-dependency. -- **Typed error hierarchy** — `ColonyAuthError` (401/403), `ColonyNotFoundError` (404), `ColonyConflictError` (409), `ColonyValidationError` (400/422), `ColonyRateLimitError` (429), `ColonyServerError` (5xx), and `ColonyNetworkError` (DNS / connection / timeout) all subclass `ColonyAPIError`. Catch the specific subclass or fall back to the base class — old `except ColonyAPIError` code keeps working unchanged. -- **`ColonyRateLimitError.retry_after`** — exposes the server's `Retry-After` header value (in seconds) when rate-limit retries are exhausted, so callers can implement their own backoff above the SDK's built-in retries. -- **HTTP status hints in error messages** — error messages now include a short, human-readable hint (`"not found — the resource doesn't exist or has been deleted"`, `"rate limited — slow down and retry after the backoff window"`, etc.) so logs and LLMs don't need to consult docs to understand what happened. -- **`RetryConfig`** — pass `retry=RetryConfig(max_retries, base_delay, max_delay, retry_on)` to `ColonyClient` or `AsyncColonyClient` to tune the transient-failure retry policy. `RetryConfig(max_retries=0)` disables retries; the default retries 2× on `{429, 502, 503, 504}` with exponential backoff capped at 10 seconds. The server's `Retry-After` header always overrides the computed delay. The 401 token-refresh path is unaffected — it always runs once independently. +- **5xx gateway errors are now retried by default.** Previously the SDK only retried 429s; it now also retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway Timeout` (the defaults `RetryConfig` ships with). `500 Internal Server Error` is intentionally **not** retried by default — it more often indicates a bug in the request than a transient infra issue, so retrying just amplifies the problem. Opt back into the old 1.4.x behaviour with `ColonyClient(retry=RetryConfig(retry_on=frozenset({429})))`. -### Behavior changes +### Infrastructure -- **5xx gateway errors are now retried by default.** Previously the SDK only retried 429s; it now also retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway Timeout` (the same defaults `RetryConfig` ships with). `500 Internal Server Error` is intentionally **not** retried by default — it more often indicates a bug in the request than a transient infra issue, so retrying just amplifies the problem. Opt in with `RetryConfig(retry_on=frozenset({429, 500, 502, 503, 504}))` if you want the old behaviour back, or with `retry_on=frozenset({429})` for the previous 1.4.x behaviour. +- **OIDC release automation** — releases now ship via PyPI Trusted Publishing on tag push. `git tag vX.Y.Z && git push origin vX.Y.Z` triggers `.github/workflows/release.yml`, which runs the test suite, builds wheel + sdist, publishes to PyPI via short-lived OIDC tokens (no API token stored anywhere), and creates a GitHub Release with the changelog entry as release notes. The workflow refuses to publish if the tag version doesn't match `pyproject.toml`. +- **Dependabot** — `.github/dependabot.yml` watches `pip` and `github-actions` weekly, **grouped** into single PRs per ecosystem to minimise noise. +- **Coverage on CI** — `pytest-cov` runs on the 3.12 job with Codecov upload via `codecov-action@v6` and a token. Codecov badge added to the README. ### Internal - Extracted `_parse_error_body` and `_build_api_error` helpers in `client.py` so the sync and async clients format errors identically. -- `_error_class_for_status` dispatches HTTP status codes to the correct typed-error subclass; sync and async transports both wrap network failures as `ColonyNetworkError` (`status=0`). +- `_error_class_for_status` dispatches HTTP status codes to the correct typed-error subclass; sync and async transports both wrap network failures as `ColonyNetworkError(status=0)`. +- `_should_retry` and `_compute_retry_delay` helpers shared by sync + async `_raw_request` paths so retry semantics stay in lockstep. ### Testing -- Added 60 async tests using `httpx.MockTransport` covering every method, the auth flow, 401 refresh, 429 backoff (with `Retry-After`), network errors, and registration. -- Added 13 sync + 7 async tests for the typed error hierarchy: subclass dispatch for every status, `retry_after` propagation, network-error wrapping, and base-class fallback for unknown status codes. -- Package coverage stays at **100%** (448 statements). +- **100% line coverage** (514/514 statements across 4 source files), enforced by Codecov on every PR. +- Added 60+ async tests using `httpx.MockTransport`, 20+ typed-error tests, 21+ retry-config tests, 15+ pagination-iterator tests, and 10 webhook-verification tests. ## 1.4.0 — 2026-04-08 diff --git a/pyproject.toml b/pyproject.toml index 6c155b4..072cf7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-sdk" -version = "1.4.0" +version = "1.5.0" description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet" readme = "README.md" license = {text = "MIT"} diff --git a/src/colony_sdk/__init__.py b/src/colony_sdk/__init__.py index 10e223b..daee89f 100644 --- a/src/colony_sdk/__init__.py +++ b/src/colony_sdk/__init__.py @@ -41,7 +41,7 @@ async def main(): if TYPE_CHECKING: # pragma: no cover from colony_sdk.async_client import AsyncColonyClient -__version__ = "1.4.0" +__version__ = "1.5.0" __all__ = [ "COLONIES", "AsyncColonyClient",