From a1e98e6161c5b5d11e610a52ecf43085bbfd400c Mon Sep 17 00:00:00 2001 From: JJasonSun <130959319+JJasonSun@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:45:00 +0800 Subject: [PATCH] chore: prepare SDK for open source --- .github/CODEOWNERS | 1 + .github/pull_request_template.md | 9 +- .github/workflows/ci.yml | 13 +- .github/workflows/public-repo-hygiene.yml | 29 ++ .github/workflows/publish-python.yml | 322 +++++++++++++++++----- CHANGELOG.md | 19 ++ CONTRIBUTING.md | 23 ++ LICENSE | 21 ++ README.md | 94 +++++-- RELEASE.md | 135 ++++----- SECURITY.md | 11 +- pyproject.toml | 3 + scripts/check_public_repo_hygiene.py | 153 ++++++++++ scripts/validate.sh | 2 + scripts/verify_distribution_artifacts.py | 147 ++++++++++ tests/test_public_repo_hygiene.py | 44 +++ 16 files changed, 848 insertions(+), 178 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/public-repo-hygiene.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 scripts/check_public_repo_hygiene.py create mode 100644 scripts/verify_distribution_artifacts.py create mode 100644 tests/test_public_repo_hygiene.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..781701c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Si40Code @Ray-56 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2f34379..58319e1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,16 +6,13 @@ Describe the SDK behavior, documentation, or release workflow change. - [ ] I kept this change within the supported server SDK scope. - [ ] I did not add browser/client-side patterns that expose CALL-E API keys. +- [ ] I did not include private collaboration links or unconfirmed public repository references. - [ ] I updated tests, examples, or docs when behavior changed. +- [ ] I updated the changelog when the change affects package users. - [ ] I ran the relevant local checks. ## Local checks ```bash -uv run pytest -q -uv run ruff check . -uv run mypy src/calle -uv run python -m py_compile examples/create_and_wait.py examples/webhook_server.py -uv build -uvx twine check dist/* +bash scripts/validate.sh ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d228dc6..03d3f8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,21 +6,28 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.11" - name: Setup uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/public-repo-hygiene.yml b/.github/workflows/public-repo-hygiene.yml new file mode 100644 index 0000000..ebf7cc5 --- /dev/null +++ b/.github/workflows/public-repo-hygiene.yml @@ -0,0 +1,29 @@ +name: Public repository hygiene + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Scan tracked text and pull request metadata + env: + PR_TITLE: ${{ github.event.pull_request.title || '' }} + PR_BODY: ${{ github.event.pull_request.body || '' }} + run: python3 scripts/check_public_repo_hygiene.py diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 0cd524b..45301d2 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -1,140 +1,312 @@ name: Publish Python package on: + release: + types: [published] workflow_dispatch: - inputs: - repository: - description: Python package index to publish. - required: true - default: testpypi - type: choice - options: - - testpypi - - pypi - auth: - description: Release identity to use. - required: true - default: token - type: choice - options: - - token - - trusted-publishing + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: - publish: + build: runs-on: ubuntu-latest - environment: ${{ inputs.repository }} - - permissions: - contents: read - id-token: write + outputs: + package_version: ${{ steps.package.outputs.version }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + + - name: Validate release source and package version + id: package + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || '' }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease || false }} + run: | + set -euo pipefail + + package_version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + echo "version=$package_version" >> "$GITHUB_OUTPUT" + + if [ "$EVENT_NAME" != "release" ]; then + echo "Manual run: validating distributions only; nothing will be published." + exit 0 + fi + + if [ "$RELEASE_PRERELEASE" = "true" ]; then + echo "::error::Prereleases are not published by this stable release workflow." + exit 1 + fi + + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::The GitHub Release tag must match vX.Y.Z." + exit 1 + fi + + if [ "${RELEASE_TAG#v}" != "$package_version" ]; then + echo "::error::The release tag does not match the pyproject.toml version." + exit 1 + fi + + git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main" + tag_commit="$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}")" + if [ "$tag_commit" != "$(git rev-parse HEAD)" ]; then + echo "::error::The checked-out commit does not match the release tag." + exit 1 + fi + if ! git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main; then + echo "::error::The release tag commit is not contained in origin/main." + exit 1 + fi + + pypi_url="https://pypi.org/pypi/calle-ai/${package_version}/json" + if ! pypi_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "$pypi_url")"; then + echo "::error::Could not check whether the package version already exists on PyPI." + exit 1 + fi + if [ "$pypi_status" = "200" ]; then + echo "::error::The package version already exists on PyPI." + exit 1 + fi + if [ "$pypi_status" != "404" ]; then + echo "::error::PyPI returned unexpected status $pypi_status during the version preflight." + exit 1 + fi - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.11" - name: Setup uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - - name: Run validation + - name: Build and validate distributions run: bash scripts/validate.sh - - name: Publish with package index token - if: inputs.auth == 'token' + - name: Upload validated distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-distributions + path: | + dist/*.whl + dist/*.tar.gz + dist/SHA256SUMS + if-no-files-found: error + retention-days: 7 + + publish: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + environment: pypi + + permissions: + contents: read + id-token: write + + steps: + - name: Download validated distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-distributions + path: release-dist/ + + - name: Revalidate downloaded distributions env: - PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }} + PACKAGE_VERSION: ${{ needs.build.outputs.package_version }} run: | set -euo pipefail - if [ "${{ inputs.repository }}" = "pypi" ]; then - token="$PYPI_API_TOKEN" - repository_url="https://upload.pypi.org/legacy/" - else - token="$TEST_PYPI_API_TOKEN" - repository_url="https://test.pypi.org/legacy/" - fi + python3 - <<'PY' + import hashlib + import os + import re + import tarfile + import zipfile + from email.parser import BytesParser + from pathlib import Path, PurePosixPath - if [ -z "$token" ]; then - echo "::error::Missing package index token for ${{ inputs.repository }}." - exit 1 - fi + directory = Path("release-dist") + expected_version = os.environ["PACKAGE_VERSION"] + expected_license = b"""MIT License - TWINE_USERNAME="__token__" TWINE_PASSWORD="$token" \ - uvx twine upload --repository-url "$repository_url" dist/* + Copyright (c) 2026 CALL-E, Inc. - - name: Publish to TestPyPI with Trusted Publishing - if: inputs.auth == 'trusted-publishing' && inputs.repository == 'testpypi' - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: - - name: Publish to PyPI with Trusted Publishing - if: inputs.auth == 'trusted-publishing' && inputs.repository == 'pypi' - uses: pypa/gh-action-pypi-publish@release/v1 + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. - - name: Verify published package + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + + entries = list(directory.iterdir()) + if any(not entry.is_file() for entry in entries): + raise SystemExit("artifact directory contains a non-file entry") + wheels = [entry for entry in entries if entry.suffix == ".whl"] + sdists = [entry for entry in entries if entry.name.endswith(".tar.gz")] + if len(wheels) != 1 or len(sdists) != 1: + raise SystemExit("artifact must contain one wheel and one source archive") + + artifact_files = (wheels[0], sdists[0]) + expected_names = {path.name for path in artifact_files} | {"SHA256SUMS"} + if {entry.name for entry in entries} != expected_names: + raise SystemExit("artifact contains an unexpected file set") + + manifest_pattern = re.compile(r"([0-9a-f]{64}) ([^/\s]+)") + checksums = {} + for line in (directory / "SHA256SUMS").read_text(encoding="utf-8").splitlines(): + match = manifest_pattern.fullmatch(line) + if match is None or match.group(2) in checksums: + raise SystemExit("checksum manifest contains an invalid entry") + checksums[match.group(2)] = match.group(1) + if set(checksums) != {path.name for path in artifact_files}: + raise SystemExit("checksum manifest does not match the artifact file set") + for path in artifact_files: + if hashlib.sha256(path.read_bytes()).hexdigest() != checksums[path.name]: + raise SystemExit(f"checksum mismatch: {path.name}") + + with zipfile.ZipFile(wheels[0]) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + license_names = [ + name + for name in archive.namelist() + if PurePosixPath(name).name == "LICENSE" and ".dist-info/licenses/" in name + ] + if len(metadata_names) != 1 or len(license_names) != 1: + raise SystemExit("wheel metadata or license file set is invalid") + metadata = BytesParser().parsebytes(archive.read(metadata_names[0])) + if ( + metadata["Name"] != "calle-ai" + or metadata["Version"] != expected_version + or metadata["License-Expression"] != "MIT" + ): + raise SystemExit("wheel package metadata does not match the release") + if archive.read(license_names[0]) != expected_license: + raise SystemExit("wheel does not contain the expected MIT license") + + with tarfile.open(sdists[0], mode="r:gz") as archive: + files = [member for member in archive.getmembers() if member.isfile()] + metadata_members = [member for member in files if PurePosixPath(member.name).name == "PKG-INFO"] + license_members = [member for member in files if PurePosixPath(member.name).name == "LICENSE"] + if len(metadata_members) != 1 or len(license_members) != 1: + raise SystemExit("source archive metadata or license file set is invalid") + metadata_stream = archive.extractfile(metadata_members[0]) + license_stream = archive.extractfile(license_members[0]) + if metadata_stream is None or license_stream is None: + raise SystemExit("source archive metadata or license could not be read") + metadata = BytesParser().parsebytes(metadata_stream.read()) + if ( + metadata["Name"] != "calle-ai" + or metadata["Version"] != expected_version + or metadata["License-Expression"] != "MIT" + ): + raise SystemExit("source archive package metadata does not match the release") + if license_stream.read() != expected_license: + raise SystemExit("source archive does not contain the expected MIT license") + PY + + rm release-dist/SHA256SUMS + + - name: Confirm version is still unpublished + env: + PACKAGE_VERSION: ${{ needs.build.outputs.package_version }} run: | set -euo pipefail - version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - if [ "${{ inputs.repository }}" = "pypi" ]; then - url="https://pypi.org/pypi/calle-ai/${version}/json" - else - url="https://test.pypi.org/pypi/calle-ai/${version}/json" + pypi_url="https://pypi.org/pypi/calle-ai/${PACKAGE_VERSION}/json" + if ! pypi_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "$pypi_url")"; then + echo "::error::Could not check whether the package version already exists on PyPI." + exit 1 fi + if [ "$pypi_status" != "404" ]; then + echo "::error::PyPI must return 404 immediately before upload; received $pypi_status." + exit 1 + fi + + - name: Publish to PyPI with Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + packages-dir: release-dist/ + + - name: Record irreversible publish boundary + run: echo "PyPI upload completed. A later verification failure does not undo publication." + verify: + if: github.event_name == 'release' + needs: [build, publish] + runs-on: ubuntu-latest + + steps: + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Verify registry metadata + env: + PACKAGE_VERSION: ${{ needs.build.outputs.package_version }} + run: | + set -euo pipefail + + url="https://pypi.org/pypi/calle-ai/${PACKAGE_VERSION}/json" for attempt in 1 2 3 4 5 6 7 8 9 10; do if curl --fail --silent --show-error "$url" >/dev/null; then exit 0 fi - - echo "Package version metadata is not visible yet, retrying in 10s..." + echo "Package metadata is not visible yet; retrying in 10 seconds." sleep 10 done - echo "::error::Published package version metadata did not become visible in time." + echo "::error::Package metadata is not visible. The upload may still have succeeded; check PyPI before retrying the release." exit 1 - name: Smoke test published package install + env: + PACKAGE_VERSION: ${{ needs.build.outputs.package_version }} run: | set -euo pipefail - version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - smoke_dir="$(mktemp -d)" + trap 'rm -rf "$smoke_dir"' EXIT python -m venv "$smoke_dir/.venv" . "$smoke_dir/.venv/bin/activate" python -m pip install --upgrade pip installed=false for attempt in 1 2 3 4 5 6 7 8 9 10; do - if [ "${{ inputs.repository }}" = "pypi" ]; then - if python -m pip install "calle-ai==$version"; then - installed=true - break - fi - elif python -m pip install \ - --index-url https://test.pypi.org/simple/ \ - --extra-index-url https://pypi.org/simple \ - "calle-ai==$version"; then + if python -m pip install --no-cache-dir "calle-ai==$PACKAGE_VERSION"; then installed=true break fi - - echo "Package install is not available yet, retrying in 10s..." + echo "Package install is not available yet; retrying in 10 seconds." sleep 10 done if [ "$installed" != "true" ]; then - echo "::error::Published package install did not become available in time." + echo "::error::Install smoke test failed. The upload may still have succeeded; check PyPI before retrying the release." exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad69341 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- MIT license and public contribution, security, and ownership information. +- A public-repository hygiene check for tracked paths, tracked text, and pull + request metadata. + +### Changed + +- Stable publishing is initiated by a versioned GitHub Release and uses PyPI + Trusted Publishing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b41d2d..74a8f9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ trusted backend services, workers, and automation systems. bash scripts/validate.sh ``` +This command verifies the OpenAPI contract, tests, lint, types, examples, +public-repository hygiene, distribution metadata, packaged license files, and +fresh wheel and source-distribution installs. + ## Local examples ```bash @@ -84,3 +88,22 @@ handling, webhook event handling, and any changed API contract surface. Do not add browser examples or patterns that expose CALL-E API keys to client code. + +Update [CHANGELOG.md](./CHANGELOG.md) when a change affects package users. Pull +request titles, descriptions, tracked paths, and tracked text must not contain +private collaboration links, unencrypted IP URLs, or references to unconfirmed +public repositories in the CALL-E GitHub organization. + +Run the standalone hygiene check with: + +```bash +python3 scripts/check_public_repo_hygiene.py +``` + +The allowlist in that script contains only repositories confirmed for public +use. Add a repository only after confirming that it is public. + +## License + +By submitting a contribution, you agree that it may be distributed under the +[MIT License](./LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b2cd53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CALL-E, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a01770a..3081a2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # calle-ai +[![PyPI version](https://img.shields.io/pypi/v/calle-ai)](https://pypi.org/project/calle-ai/) +[![Python versions](https://img.shields.io/pypi/pyversions/calle-ai)](https://pypi.org/project/calle-ai/) +[![CI](https://github.com/CALLE-AI/server-sdk-python/actions/workflows/ci.yml/badge.svg)](https://github.com/CALLE-AI/server-sdk-python/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) + Python server SDK for the CALL-E Developer API. Use this SDK from backend services, workers, and other trusted server @@ -11,7 +16,16 @@ environments. Do not expose CALL-E API keys in browser code. - SDK guide: - API Reference: - Webhooks: -- Changelog: +- Product changelog: +- TypeScript SDK: + +## SDK surface + +- `client.calls` creates, reads, and polls call tasks and lists call events. +- `client.goals` lists and reads published Goals and runs them with structured + results. +- `examples/webhook_server.py` shows how to receive current terminal webhook + events. ## Install @@ -21,11 +35,8 @@ Install the stable package from PyPI: pip install calle-ai ``` -Pin the current stable release when your deployment process requires exact package reproducibility: - -```bash -pip install calle-ai==0.7.0 -``` +For reproducible deployments, pin the package version selected by your +dependency-management workflow. Use a local checkout for development and package smoke tests: @@ -33,6 +44,30 @@ Use a local checkout for development and package smoke tests: bash scripts/validate.sh ``` +Python 3.11 or newer is required. + +## Configuration + +Create one `CalleClient` and close it when your process no longer needs it: + +| Option | Required | Description | +| --- | --- | --- | +| `api_key` | Yes | CALL-E API key. Load it from a server-side secret store or environment variable. | +| `base_url` | No | API base URL. Defaults to `https://api.heycall-e.com`. | +| `timeout` | No | HTTP request timeout in seconds. Defaults to `30.0`. | +| `http_client` | No | Configured `httpx.Client` used as-is. It must define the required base URL, authentication, transport, and timeout. | + +With the default HTTP client, use `CalleClient` as a context manager so the SDK +closes its connection pool: + +```python +import os +from calle import CalleClient + +with CalleClient(api_key=os.environ["CALLE_API_KEY"]) as client: + call = client.calls.get("call_123") +``` + ## Examples Set the API key before running call examples: @@ -161,37 +196,46 @@ print(call["task_completed"], call["completion_confidence"], call["evidence"]) print(call["recipients"][0]["structured_result"]) ``` +## Error handling + +The SDK exports errors for API responses, authentication, rate limits, +timeouts, and connection failures: + +```python +import os +from calle import CalleAPIError, CalleClient + +with CalleClient(api_key=os.environ["CALLE_API_KEY"]) as client: + try: + client.calls.get("call_123") + except CalleAPIError as error: + print(error.status_code, error.code, error.details) + raise +``` + ## Release This repository publishes the Python distribution `calle-ai`. Application code imports it as `calle`. -See [RELEASE.md](./RELEASE.md) for the release checklist, GitHub Actions -workflow, and post-publish install smoke test. - -Prerequisites: +Merging to `main` runs CI and does not publish the package. A stable publish is +triggered only by publishing a GitHub Release with a matching `vX.Y.Z` tag; +manually running the workflow performs a dry run. See [RELEASE.md](./RELEASE.md) +for release gates and registry checks. -- Create a PyPI API token and add it to this repository as the GitHub Actions secret `PYPI_API_TOKEN`. -- Keep the package version in `pyproject.toml` unique before each publish. +## Support and security -Manual stable PyPI publish: +Use [GitHub Issues](https://github.com/CALLE-AI/server-sdk-python/issues) for +reproducible SDK bugs and feature requests. Do not report vulnerabilities in a +public issue. Follow [SECURITY.md](./SECURITY.md) for private reporting. -1. Open the `Publish Python package` GitHub Actions workflow. -2. Run it from `main` with repository `pypi` and auth `token`. -3. Verify install in a temporary environment: - -```bash -python -m venv .venv -. .venv/bin/activate -pip install calle-ai==0.7.0 -python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()' -``` +## License -The current stable version is `0.7.0`. Do not reuse a previously published -PyPI version. +This project is licensed under the [MIT License](./LICENSE). ## Project Documents - [CONTRIBUTING.md](./CONTRIBUTING.md) +- [CHANGELOG.md](./CHANGELOG.md) - [SECURITY.md](./SECURITY.md) - [RELEASE.md](./RELEASE.md) diff --git a/RELEASE.md b/RELEASE.md index 6b8ff06..42c3071 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,98 +1,105 @@ # Release -This repository publishes the Python distribution `calle-ai`. Application code imports it as `calle`. +This repository publishes the Python distribution `calle-ai`. Application code +imports it as `calle`. -## Current status +## Release infrastructure -TestPyPI has the prerelease package: +Production publishing uses PyPI Trusted Publishing. Configure the publisher +with these values: -```text -calle-ai==0.1.0b1 -``` +- Owner: `CALLE-AI` +- Repository: `server-sdk-python` +- Workflow: `publish-python.yml` +- Environment: `pypi` -The current production PyPI release version is: +Configure the GitHub `pypi` environment with required reviewers, prevent +self-review, and restrict it to protected release tags. The workflow does not +use a long-lived PyPI token. -```text -calle-ai==0.7.0 -``` +## Prepare a release -For this release, use token-based PyPI publishing with the GitHub Actions secret `PYPI_API_TOKEN`. +1. Set a new, previously unpublished stable version in `pyproject.toml` and + keep the OpenAPI and generated-client version metadata aligned. +2. Move the relevant entries from `Unreleased` in [CHANGELOG.md](./CHANGELOG.md) + into a section for that version and date. +3. Run the release gates: -## Release gates + ```bash + bash scripts/validate.sh + ``` -Run these checks before publishing: - -```bash -bash scripts/validate.sh -``` +4. Merge the release preparation pull request into `main` after CI and review + complete. The validation script checks the OpenAPI contract, tests, lint, types, -examples, distribution metadata, wheel install, source distribution install, -and Goal wrapper plus generated-model imports from fresh virtual environments. +examples, public-repository hygiene, distribution metadata, packaged license +files, and fresh wheel and source-distribution installs. -## Test API Goal smoke +## Publish a stable release -Before publishing a release that changes Goal behavior, run the local release -candidate against a published Goal in the test environment: +1. Create a `vX.Y.Z` tag on the intended commit from `main`. +2. Publish a non-prerelease GitHub Release for that tag. +3. Approve the `pypi` environment deployment after checking the tag, version, + commit, changelog, and build result. +4. Confirm that the publish and post-publish verification jobs complete. -```bash -export CALLE_API_KEY="" -export CALLE_BASE_URL="https://test-api.heycall-e.com" -export CALLE_GOAL_ID="" -export CALLE_GOAL_PHONE="" -export CALLE_GOAL_VARIABLES='{"name":"Alex"}' -export CALLE_IDEMPOTENCY_KEY="" -uv run python examples/run_goal_and_wait.py -``` +The workflow rejects a tag that does not exactly match `vX.Y.Z`, differs from +the version in `pyproject.toml`, or points to a commit not contained in +`origin/main`. Before installing project dependencies, it also requires PyPI to +return an exact `404` for the candidate version; an existing version, network +failure, or any other response stops the release. + +The workflow builds and validates the wheel and source distribution once and +uploads them with a SHA-256 manifest. The publish job downloads that artifact +and rechecks its exact file set, checksums, package version, MIT metadata, and +LICENSE before handing the same files to Trusted Publishing. Only the publish +job receives `id-token: write`. + +Publishing and merging are separate actions: a push or merge to `main` never +publishes a package. -This smoke test creates a real phone call. Use an authorized test number and a -new idempotency key for a new logical test. Reuse the same key only when -retrying that exact request. Record the returned Goal Run id and verify that -exactly one of `result` or `error` is non-null. +## Manual dry run -## Stable PyPI publish +Running `Publish Python package` with `workflow_dispatch` only builds, +validates, and uploads the distribution artifact. A manual run never enters the +PyPI environment and never publishes. -1. Confirm `pyproject.toml` has a unique stable version. -2. Confirm GitHub Actions secret `PYPI_API_TOKEN` is configured. -3. Open the `Publish Python package` workflow in GitHub Actions. -4. Run the workflow from `main` with repository `pypi` and auth `token`. -5. Confirm the workflow completes the post-publish install smoke test. +## Post-publish verification -Manual verification: +After upload, the workflow waits for exact-version metadata on PyPI and installs +that version into a fresh virtual environment before importing the main client +and generated Goal models. + +An upload is irreversible. If registry or install smoke verification fails, +the package may already be published. Check PyPI before retrying or creating +another release; a red smoke-test job does not mean the upload was rolled back. + +For a manual check from the release commit: ```bash -curl --fail --silent --show-error https://pypi.org/pypi/calle-ai/json >/dev/null +version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" +curl --fail --silent --show-error "https://pypi.org/pypi/calle-ai/${version}/json" >/dev/null tmpdir="$(mktemp -d)" python -m venv "$tmpdir/.venv" . "$tmpdir/.venv/bin/activate" python -m pip install --upgrade pip -python -m pip install calle-ai==0.7.0 +python -m pip install "calle-ai==$version" python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()' ``` ## Version rules -- Patch releases fix SDK wrapper bugs, type issues, packaging metadata, README examples, or distribution issues without changing public API behavior. +- Patch releases fix SDK wrapper bugs, type issues, packaging metadata, README + examples, or distribution issues without changing public API behavior. - Minor releases add backward-compatible API fields, endpoints, or SDK helpers. -- Major releases make breaking public API, method signature, stable error, or webhook delivery contract changes. - -Keep TypeScript, Python, OpenAPI, and public docs versions aligned by default. A single-language patch is allowed only when the shared API contract and cross-language behavior do not change. - -Do not reuse a previously published version. PyPI package versions are immutable. - -## Registry identity notes - -Token-based publishing requires a PyPI API token stored as the GitHub Actions secret `PYPI_API_TOKEN`. - -PyPI Trusted Publishing is a future optional improvement. To switch later, configure the PyPI publisher for: - -- Owner: `CALLE-AI` -- Repository: `server-sdk-python` -- Workflow filename: `publish-python.yml` -- Environment name for PyPI: `pypi` -- Project name: `calle-ai` +- Major releases make breaking public API, method signature, stable error, or + webhook delivery contract changes. -When using API tokens, run the workflow with auth `token`. When Trusted Publishing is configured later, run the workflow with auth `trusted-publishing`. +Keep TypeScript, Python, OpenAPI, and public docs versions aligned by default. A +single-language patch is allowed only when the shared API contract and +cross-language behavior do not change. -TestPyPI and PyPI are separate registries. TestPyPI success does not prove production PyPI availability. +Do not reuse a previously published version. PyPI package versions are +immutable. diff --git a/SECURITY.md b/SECURITY.md index d31fcfd..8ed5fd7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,14 +2,15 @@ ## Supported versions -The SDK is in Phase 1 beta preparation. Security fixes are applied to the -current beta line. +Security fixes are applied to the current release line. Users should upgrade +to the latest published version before reporting an issue that may already be +fixed. ## Reporting a vulnerability -Do not open a public issue for suspected vulnerabilities. Use GitHub private -vulnerability reporting if it is enabled for this repository; otherwise contact -the CALL-E maintainers directly. +Do not open a public issue for suspected vulnerabilities. Email +`support@heycall-e.com` with the subject `Security report`. You may also use +GitHub private vulnerability reporting if it is enabled for this repository. Send a private report to the CALL-E maintainers with: diff --git a/pyproject.toml b/pyproject.toml index 33050f1..a0df29d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,8 @@ name = "calle-ai" version = "0.7.0" description = "Python server SDK for the CALL-E Developer API." readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.11" keywords = ["calle", "call-e", "voice-ai", "server-sdk", "webhooks"] classifiers = [ @@ -42,6 +44,7 @@ dev = [ ] [tool.pytest.ini_options] +pythonpath = ["."] testpaths = ["tests"] [tool.ruff] diff --git a/scripts/check_public_repo_hygiene.py b/scripts/check_public_repo_hygiene.py new file mode 100644 index 0000000..1f1403b --- /dev/null +++ b/scripts/check_public_repo_hygiene.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Reject private-context references before they enter the public repository.""" + +from __future__ import annotations + +import ipaddress +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + +PUBLIC_CALLE_REPOS = frozenset( + { + "awesome-phone-call-agents", + "call-e-integrations", + "calle-docs", + "n8n-nodes-calle", + "server-sdk-python", + "server-sdk-typescript", + } +) + +COLLABORATION_HOSTS = ( + "atlassian.net", + "docs.google.com", + "drive.google.com", + "feishu.cn", + "larksuite.com", + "linear.app", + "notion.site", + "notion.so", + "slack.com", +) + +URL_RE = re.compile(r"https?://[^\s<>{}\"']+", re.IGNORECASE) +CALLE_REPO_RE = re.compile( + r"(? str: + return f"{self.location}: {self.kind}" + + +def _is_collaboration_host(hostname: str) -> bool: + hostname = hostname.lower().rstrip(".") + if any( + hostname == suffix or hostname.endswith(f".{suffix}") + for suffix in COLLABORATION_HOSTS + ): + return True + + return any(label.startswith("gitlab") for label in hostname.split(".")) + + +def _is_ip_address(hostname: str) -> bool: + try: + ipaddress.ip_address(hostname) + except ValueError: + return False + return True + + +def scan_text(label: str, text: str) -> list[Finding]: + findings: list[Finding] = [] + + for line_number, line in enumerate(text.splitlines(), start=1): + for match in URL_RE.finditer(line): + parsed = urlsplit(match.group(0)) + hostname = parsed.hostname + if hostname is None: + continue + + location = f"{label}:{line_number}:{match.start() + 1}" + if _is_collaboration_host(hostname): + findings.append(Finding(location, INTERNAL_LINK)) + if parsed.scheme.lower() == "http" and _is_ip_address(hostname): + findings.append(Finding(location, RAW_HTTP_IP)) + + for match in CALLE_REPO_RE.finditer(line): + repository = match.group(1).lower().removesuffix(".git") + if repository not in PUBLIC_CALLE_REPOS: + location = f"{label}:{line_number}:{match.start() + 1}" + findings.append(Finding(location, UNCONFIRMED_REPO)) + + return findings + + +def _tracked_paths() -> list[Path]: + result = subprocess.run( + ["git", "ls-files", "-z"], + check=True, + stdout=subprocess.PIPE, + ) + return [Path(os.fsdecode(raw_path)) for raw_path in result.stdout.split(b"\0") if raw_path] + + +def _read_tracked_text(path: Path) -> str | None: + try: + if path.is_symlink(): + raw = os.fsencode(os.readlink(path)) + else: + raw = path.read_bytes() + except FileNotFoundError: + return None + + if b"\0" in raw: + return None + return raw.decode("utf-8", errors="replace") + + +def main() -> int: + findings: list[Finding] = [] + + for path in _tracked_paths(): + relative_path = path.as_posix() + findings.extend(scan_text(f"path {relative_path}", relative_path)) + + text = _read_tracked_text(path) + if text is not None: + findings.extend(scan_text(relative_path, text)) + + findings.extend(scan_text("pull request title", os.environ.get("PR_TITLE", ""))) + findings.extend(scan_text("pull request body", os.environ.get("PR_BODY", ""))) + + if not findings: + print("Public repository hygiene check passed.") + return 0 + + print("Public repository hygiene check failed:", file=sys.stderr) + for finding in findings: + print(f"- {finding.describe()}", file=sys.stderr) + print( + "Remove the reference, or confirm a public repository and update the explicit allowlist.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate.sh b/scripts/validate.sh index 1e19863..0c3e06e 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -11,7 +11,9 @@ uv run python -m py_compile \ examples/create_and_wait.py \ examples/run_goal_and_wait.py \ examples/webhook_server.py +python3 scripts/check_public_repo_hygiene.py uv build uvx twine check dist/* +uv run python scripts/verify_distribution_artifacts.py dist --write-manifest bash scripts/smoke_install_dist.sh dist/*.whl bash scripts/smoke_install_dist.sh dist/*.tar.gz diff --git a/scripts/verify_distribution_artifacts.py b/scripts/verify_distribution_artifacts.py new file mode 100644 index 0000000..10cc23b --- /dev/null +++ b/scripts/verify_distribution_artifacts.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Validate release artifacts and create or check their checksum manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import tarfile +import tomllib +import zipfile +from email.parser import BytesParser +from pathlib import Path, PurePosixPath + +MANIFEST_NAME = "SHA256SUMS" +MANIFEST_LINE_RE = re.compile(r"([0-9a-f]{64}) ([^/\s]+)") + + +def _project_version() -> str: + with Path("pyproject.toml").open("rb") as stream: + return str(tomllib.load(stream)["project"]["version"]) + + +def _archive_files(directory: Path) -> tuple[Path, Path]: + wheel_files = sorted(directory.glob("*.whl")) + source_files = sorted(directory.glob("*.tar.gz")) + if len(wheel_files) != 1 or len(source_files) != 1: + raise ValueError("artifact directory must contain one wheel and one source archive") + return wheel_files[0], source_files[0] + + +def _metadata_fields(raw_metadata: bytes) -> tuple[str | None, str | None, str | None]: + metadata = BytesParser().parsebytes(raw_metadata) + return metadata["Name"], metadata["Version"], metadata["License-Expression"] + + +def _verify_wheel(path: Path, expected_version: str, expected_license: bytes) -> None: + with zipfile.ZipFile(path) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + license_names = [ + name + for name in archive.namelist() + if PurePosixPath(name).name == "LICENSE" and ".dist-info/licenses/" in name + ] + if len(metadata_names) != 1 or len(license_names) != 1: + raise ValueError("wheel must contain one METADATA file and one dist-info license") + + name, version, license_expression = _metadata_fields(archive.read(metadata_names[0])) + if name != "calle-ai" or version != expected_version: + raise ValueError("wheel name or version metadata does not match the release") + if license_expression != "MIT": + raise ValueError("wheel METADATA must contain License-Expression: MIT") + if archive.read(license_names[0]) != expected_license: + raise ValueError("wheel license differs from the repository LICENSE") + + +def _verify_sdist(path: Path, expected_version: str, expected_license: bytes) -> None: + with tarfile.open(path, mode="r:gz") as archive: + files = [member for member in archive.getmembers() if member.isfile()] + metadata_members = [member for member in files if PurePosixPath(member.name).name == "PKG-INFO"] + license_members = [member for member in files if PurePosixPath(member.name).name == "LICENSE"] + if len(metadata_members) != 1 or len(license_members) != 1: + raise ValueError("source archive must contain one PKG-INFO and one license file") + + metadata_stream = archive.extractfile(metadata_members[0]) + license_stream = archive.extractfile(license_members[0]) + if metadata_stream is None or license_stream is None: + raise ValueError("source archive metadata or license could not be read") + + name, version, license_expression = _metadata_fields(metadata_stream.read()) + if name != "calle-ai" or version != expected_version: + raise ValueError("source archive name or version metadata does not match the release") + if license_expression != "MIT": + raise ValueError("source archive PKG-INFO must contain License-Expression: MIT") + if license_stream.read() != expected_license: + raise ValueError("source archive license differs from the repository LICENSE") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _write_manifest(directory: Path, artifacts: tuple[Path, Path]) -> None: + manifest = directory / MANIFEST_NAME + contents = "".join(f"{_sha256(path)} {path.name}\n" for path in artifacts) + manifest.write_text(contents, encoding="utf-8") + + +def _check_manifest(directory: Path, artifacts: tuple[Path, Path]) -> None: + manifest = directory / MANIFEST_NAME + entries: dict[str, str] = {} + for line in manifest.read_text(encoding="utf-8").splitlines(): + match = MANIFEST_LINE_RE.fullmatch(line) + if match is None or match.group(2) in entries: + raise ValueError("checksum manifest has an invalid or duplicate entry") + entries[match.group(2)] = match.group(1) + + expected_names = {path.name for path in artifacts} + if set(entries) != expected_names: + raise ValueError("checksum manifest does not match the artifact file set") + for path in artifacts: + if _sha256(path) != entries[path.name]: + raise ValueError(f"checksum mismatch: {path.name}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=Path) + parser.add_argument("--expected-version") + manifest_mode = parser.add_mutually_exclusive_group(required=True) + manifest_mode.add_argument("--write-manifest", action="store_true") + manifest_mode.add_argument("--check-manifest", action="store_true") + arguments = parser.parse_args() + + project_version = _project_version() + expected_version = arguments.expected_version or project_version + if expected_version != project_version: + raise ValueError("expected version does not match pyproject.toml") + + expected_files = {MANIFEST_NAME} if arguments.check_manifest else set() + artifacts = _archive_files(arguments.directory) + expected_files.update(path.name for path in artifacts) + if (arguments.directory / ".gitignore").is_file(): + expected_files.add(".gitignore") + actual_files = {path.name for path in arguments.directory.iterdir() if path.is_file()} + if actual_files != expected_files: + raise ValueError("artifact directory contains an unexpected file set") + + expected_license = Path("LICENSE").read_bytes() + _verify_wheel(artifacts[0], expected_version, expected_license) + _verify_sdist(artifacts[1], expected_version, expected_license) + + if arguments.write_manifest: + _write_manifest(arguments.directory, artifacts) + else: + _check_manifest(arguments.directory, artifacts) + + print("Distribution artifacts verified.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_public_repo_hygiene.py b/tests/test_public_repo_hygiene.py new file mode 100644 index 0000000..4617513 --- /dev/null +++ b/tests/test_public_repo_hygiene.py @@ -0,0 +1,44 @@ +from scripts.check_public_repo_hygiene import ( + INTERNAL_LINK, + RAW_HTTP_IP, + UNCONFIRMED_REPO, + scan_text, +) + + +def _kinds(text: str) -> set[str]: + return {finding.kind for finding in scan_text("pull request body", text)} + + +def test_flags_private_context_without_echoing_source() -> None: + internal_link = "https://" + "git" + "lab.example.invalid/team/project" + raw_ip = "http://" + "192.0.2.10:8080/path" + unconfirmed_repo = "CALLE" + "-AI/private-repository" + source = f"{internal_link}\n{raw_ip}\n{unconfirmed_repo}" + + findings = scan_text("pull request body", source) + + assert {finding.kind for finding in findings} == { + INTERNAL_LINK, + RAW_HTTP_IP, + UNCONFIRMED_REPO, + } + assert all(internal_link not in finding.describe() for finding in findings) + assert all(raw_ip not in finding.describe() for finding in findings) + assert all(unconfirmed_repo not in finding.describe() for finding in findings) + + +def test_flags_common_collaboration_links() -> None: + collaboration_link = "https://" + "workspace." + "slack.com/archives/channel" + + assert _kinds(collaboration_link) == {INTERNAL_LINK} + + +def test_allows_confirmed_public_sdk_repositories() -> None: + source = ( + "CALLE-AI/server-sdk-python.git " + "CALLE-AI/server-sdk-typescript " + "CALLE-AI/calle-docs" + ) + + assert scan_text("README.md", source) == []