one release path, and the docs say the one that runs

RELEASE.md described GitHub Actions, GitHub secrets and three bin/ scripts
that each took a PyPI token off the environment. None of that publishes this
repo — .hanzo/workflows/publish-pypi.yml does, reading the token from KMS —
so the scripts are gone and the page describes the lane, its tag mapping, and
what to check before a tag makes a version permanent.

LLM.md carries the published state: hanzoai 3.2.13 with get_keys/get_tools,
and the KMS route that a 404 will otherwise misreport as a missing secret.
This commit is contained in:
2026-08-14 17:51:02 -07:00
parent 80c31f3534
commit 0f5676c200
6 changed files with 56 additions and 629 deletions
+21 -11
View File
@@ -12,21 +12,31 @@ OUT, never duplicate. Full spec: `~/work/hanzo/SDK-ARCHITECTURE.md`.
## Install / run
```bash
uv pip install "hanzoai @ git+https://github.com/hanzoai/python-sdk" # the current client
pip install hanzoai==3.2.13 # the current client
pip install hanzo # agents + MCP + orchestration helpers
uv sync --all-packages # dev: whole workspace
uv run pytest tests/ -v # tests
```
**PyPI is behind, and the README says so instead of pretending.** `hanzoai` on
PyPI is **3.2.1**; this tree is 3.2.11, and v3.2.3 … v3.2.10 are tagged on the
forge with nothing published for any of them. The gap is not cosmetic:
3.2.1 was cut before the default version left the operation ids, so it carries
**PyPI serves this tree again.** `hanzoai` had sat at **3.2.1** while v3.2.3 …
v3.2.12 were tagged with nothing published, and the gap was not cosmetic: 3.2.1
predates the default version leaving the operation ids, so it carries
`get_v1_keys`/`get_v1_tools` (182 api modules, 2172 models) plus the retired flat
`hanzoai/api` tree, while this one carries `get_keys`/`get_tools`. A README that
printed `pip install hanzoai` above a `get_keys()` quickstart would be handing
the reader a client that cannot run it, so the install line is the git one until
a publish lands.
`hanzoai/api` tree. **3.2.13** carries `get_keys`/`get_tools` and is what the
README's quickstart runs on. The same tag published 13 sibling packages that had
also drifted behind — `hanzo-mcp` 0.15.14, `hanzo-tools` 0.3.5, `hanzo-kms`
1.1.1, `hanzo-network` 0.1.4, seven `hanzo-tools-*`, and first releases of
`hanzo-flags` and `hanzo-research`.
**What had stopped every one of them** is worth keeping, because it reads like a
missing secret and is not one. The job asked KMS for
`/v1/kms/orgs/hanzo/secrets/<path>/<key>` and parsed `.secret.value`. KMS serves
`GET /v1/kms/secrets/<path>/<key>?env=<env>` and answers `{"env","name","value"}`,
taking the org from the caller's own token claim. A wrong route 404s exactly like
an unseeded path, so the log said "KMS holds no PyPI token" about a token that was
sitting at `hanzo`/`prod`/`python-sdk-publish`/`PYPI_TOKEN` the whole time. The
route the kms-operator uses is the one that answers; check a read against a secret
known to exist before believing a 404.
## Console-script law — only the native binary is called `hanzo`
@@ -253,8 +263,8 @@ have. It reads `analytics_api` now, off the client.
seven hand-written modules beside it (`config`, `mcp`, `protocols`, `session`, `zap`, …).
`pkg/hanzoai/{api,models}` is GONE from git; if a checkout still shows `resources/` or
`types/` on disk they are untracked leftovers of that surface, and the wheel excludes them
— a built 3.2.11 wheel is 2660 files of `hanzoai/cloud/` and those seven modules, nothing
else.
— the published 3.2.13 wheel is 2667 files of `hanzoai/cloud/` and those seven modules,
nothing else.
- `pkg/hanzo/src/hanzo/cli.py` — the `hanzo` CLI command tree.
- `pkg/hanzo-mcp/` — MCP server; tools via `[project.entry-points."hanzo.tools"]`.
- `pkg/hanzo-tools-*/` — one concern each; 37 of the 38 register a `TOOLS` list under the
+35 -176
View File
@@ -1,194 +1,53 @@
# Release Process for Hanzo Python SDK
# Releasing
This document describes the automated release process for all Python packages in this repository.
One lane: `.hanzo/workflows/publish-pypi.yml`, on the git.hanzo.ai runner fleet.
It reads the PyPI token from KMS at publish time — nothing else in this repo
uploads, and no token is ever handed to a shell here.
## 📦 Packages
Bump the version, commit, push a tag:
The following Python packages are managed in this repository:
- **hanzo** - Core Hanzo AI SDK
- **hanzo-network** - Network utilities for Hanzo AI
- **hanzo-mcp** - Model Context Protocol implementation
- **hanzo-agents** - Agent framework for Hanzo AI
- **hanzo-memory** - Memory management for AI agents
- **hanzo-aci** - AI Chain Infrastructure
- **hanzo-repl** - REPL for Hanzo AI
## 🚀 Automated Release Process
### CI/CD Pipeline
The release process is fully automated through GitHub Actions with two methods:
#### Method 1: Automatic Version Detection (Recommended)
**Every push to `main` branch automatically:**
1. Runs all tests
2. Checks each package's version against PyPI
3. Publishes any packages with new versions
4. Skips packages already published
**To release this way:**
```bash
# Update version in pyproject.toml
# Commit and push to main
git add -A
git commit -m "bump: hanzo-mcp to v1.2.3"
# pkg/hanzo-mcp/pyproject.toml -> version = "0.15.15"
git commit -am "hanzo-mcp 0.15.15"
git push origin main
# CI automatically publishes if version is new!
git tag hanzo-mcp-0.15.15 && git push origin hanzo-mcp-0.15.15
```
#### Method 2: Tag-based Release
1. **Tests Run First**: All tests must pass before any package is published
2. **Tag-based Triggers**: Pushing a tag triggers the release process
3. **Automatic PyPI Upload**: Packages are automatically built and uploaded to PyPI
The tag names the packages:
### Workflow Files
| tag | publishes |
|---|---|
| `v3.2.13` | every workspace package |
| `hanzoai-v3.2.13` | the root `hanzoai` client alone |
| `hanzo-tools-0.3.5` | every `pkg/hanzo-tools-*` |
| `hanzo-mcp-0.15.15` | the matching `pkg/<name>`, else `hanzo` |
- **`.github/workflows/publish-pypi.yml`**: Main publishing workflow
- **`.github/workflows/hanzo-packages-ci.yml`**: CI with integrated publishing
- **`.github/workflows/test.yml`**: Test suite that must pass
A `v*` tag is not as broad as it looks: uploads pass `--skip-existing`, so a
package whose version is already on PyPI is a no-op. What it does publish is
every package whose in-tree version is new — which is the point, and also the
reason to look before you tag.
## 📝 How to Release
Watch the run under Actions on git.hanzo.ai. Versions are semver, and PyPI never
lets a version be replaced: a bad release is fixed by a higher one.
### Option 1: Automatic (Easiest) 🎯
## Before you tag
Simply update the version and push to main:
Build what the lane will build and read the metadata you are about to publish —
the registry page is the first thing a reader sees, and it is permanent.
```bash
# Update version in package's pyproject.toml
vim pkg/hanzo-mcp/pyproject.toml # Change version = "1.2.3"
# Commit and push
git add -A
git commit -m "Release hanzo-mcp v1.2.3"
git push origin main
# CI will automatically detect and publish the new version!
python -m build . --outdir dist # or pkg/<name>
python -m twine check dist/*
```
### Option 2: Using Tags
`twine check` catches a README that will not render. It does not catch a missing
one: a package with no `readme`, `license` or `authors` in its `pyproject.toml`
uploads happily and lands as a blank page.
#### Release All Packages
## If the lane fails
To release all packages with the same version:
```bash
# Update version in all pyproject.toml files
# Then create and push a tag
git tag v1.2.3
git push origin v1.2.3
```
#### Release Individual Package
To release a specific package:
```bash
# Update version in the specific package's pyproject.toml
# Then create and push a package-specific tag
git tag hanzo-mcp-1.2.3
git push origin hanzo-mcp-1.2.3
```
### Tag Naming Convention
- **`v*`** - Releases all packages (e.g., `v1.2.3`)
- **`hanzo-*`** - Releases the main hanzo package (e.g., `hanzo-1.2.3`)
- **`hanzo-network-*`** - Releases hanzo-network package
- **`hanzo-mcp-*`** - Releases hanzo-mcp package
- **`hanzo-agents-*`** - Releases hanzo-agents package
- **`hanzo-memory-*`** - Releases hanzo-memory package
- **`hanzo-aci-*`** - Releases hanzo-aci package
- **`hanzo-repl-*`** - Releases hanzo-repl package
## 🔧 Manual Release (Emergency)
If automated release fails, you can manually publish:
```bash
# Set PyPI token
export PYPI_TOKEN=your_token_here
# Publish all packages
./bin/publish-all-packages.sh
# Or publish specific package
./bin/publish-all-packages.sh hanzo-mcp
```
## ✅ Release Checklist
Before creating a release tag:
1. [ ] Update version in `pyproject.toml` file(s)
2. [ ] Update CHANGELOG if applicable
3. [ ] Ensure all tests pass locally
4. [ ] Commit all changes
5. [ ] Create and push tag
## 🔒 Security
- PyPI tokens are stored as GitHub secrets
- Use `HANZO_PYPI_TOKEN` or `PYPI_TOKEN` secret names
- Tokens have package-specific or organization-wide permissions
## 📊 Release Status
You can monitor release status at:
- [GitHub Actions](https://github.com/hanzoai/python-sdk/actions)
- [PyPI - hanzo](https://pypi.org/project/hanzo/)
- [PyPI - hanzo-network](https://pypi.org/project/hanzo-network/)
- [PyPI - hanzo-mcp](https://pypi.org/project/hanzo-mcp/)
- [PyPI - hanzo-agents](https://pypi.org/project/hanzo-agents/)
- [PyPI - hanzo-memory](https://pypi.org/project/hanzo-memory/)
- [PyPI - hanzo-aci](https://pypi.org/project/hanzo-aci/)
- [PyPI - hanzo-repl](https://pypi.org/project/hanzo-repl/)
## 🐛 Troubleshooting
### Tests Failing
If tests fail, the release will not proceed. Check:
- Test logs in GitHub Actions
- Local test execution with `pytest`
### PyPI Upload Fails
Common issues:
- Version already exists on PyPI
- Invalid PyPI token
- Network issues
Solutions:
- Bump version number
- Check GitHub secrets configuration
- Retry the workflow
### Package Not Publishing
Ensure:
- Tag matches the naming convention
- Package directory exists in `pkg/`
- `pyproject.toml` is properly configured
## 📈 Version Management
We follow semantic versioning (MAJOR.MINOR.PATCH):
- **MAJOR**: Breaking API changes
- **MINOR**: New features, backward compatible
- **PATCH**: Bug fixes, backward compatible
## 🤝 Contributing
When adding a new package:
1. Create package directory in `pkg/`
2. Add `pyproject.toml` with proper configuration
3. Update CI workflows to include the new package
4. Add package to this documentation
---
For questions or issues, please open an issue on [GitHub](https://github.com/hanzoai/python-sdk/issues).
Read the job log — every failure mode it knows about prints a line saying which
credential or path it wanted. The KMS read is the one worth naming: KMS serves
`GET /v1/kms/secrets/<path>/<name>?env=<env>` and takes the org from the caller's
token, so a request in any other shape 404s exactly like an unseeded path. Prove
a route against a secret you know exists before concluding a secret is missing.
-294
View File
@@ -1,294 +0,0 @@
#!/usr/bin/env python3
"""
Check package versions and publish to PyPI if newer version is available.
This script is designed to run in CI on every push to main.
"""
import os
import re
import sys
import json
import subprocess
import urllib.error
import urllib.request
from typing import Tuple, Optional
from pathlib import Path
# Colors for output
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m" # No Color
def print_info(msg: str):
print(f"{GREEN}[INFO]{NC} {msg}")
def print_warn(msg: str):
print(f"{YELLOW}[WARN]{NC} {msg}")
def print_error(msg: str):
print(f"{RED}[ERROR]{NC} {msg}")
def print_action(msg: str):
print(f"{BLUE}[ACTION]{NC} {msg}")
def get_local_version(package_dir: Path) -> Optional[str]:
"""Extract version from pyproject.toml."""
pyproject_path = package_dir / "pyproject.toml"
if not pyproject_path.exists():
return None
with open(pyproject_path, "r") as f:
content = f.read()
# Match version = "x.y.z" or version = 'x.y.z'
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return None
def get_pypi_version(package_name: str) -> Optional[str]:
"""Get the latest version from PyPI."""
url = f"https://pypi.org/pypi/{package_name}/json"
try:
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
data = json.loads(response.read())
return data.get("info", {}).get("version")
except urllib.error.HTTPError as e:
if e.code == 404:
# Package doesn't exist on PyPI yet
return None
raise
except Exception as e:
print_warn(f"Could not fetch PyPI version for {package_name}: {e}")
return None
def parse_version(version: str) -> Tuple[int, ...]:
"""Parse version string to tuple for comparison."""
# Remove any pre-release or build metadata
version = re.split(r"[-+]", version)[0]
return tuple(int(x) for x in version.split("."))
def is_newer_version(local_version: str, pypi_version: Optional[str]) -> bool:
"""Check if local version is newer than PyPI version."""
if pypi_version is None:
# Package doesn't exist on PyPI
return True
try:
local_tuple = parse_version(local_version)
pypi_tuple = parse_version(pypi_version)
return local_tuple > pypi_tuple
except (ValueError, AttributeError):
# If we can't parse versions, compare as strings
return local_version != pypi_version
def build_package(package_dir: Path) -> bool:
"""Build the Python package."""
print_info(f"Building package in {package_dir}")
# Clean previous builds
for dir_name in ["dist", "build"]:
dir_path = package_dir / dir_name
if dir_path.exists():
subprocess.run(["rm", "-rf", str(dir_path)], check=True)
# Remove .egg-info directories
for egg_info in package_dir.glob("*.egg-info"):
subprocess.run(["rm", "-rf", str(egg_info)], check=True)
# Build the package
result = subprocess.run(["python", "-m", "build"], cwd=package_dir, capture_output=True, text=True)
if result.returncode != 0:
print_error(f"Build failed: {result.stderr}")
return False
return True
def publish_package(package_dir: Path, package_name: str) -> bool:
"""Publish package to PyPI."""
print_action(f"Publishing {package_name} to PyPI")
# Check for PyPI token
pypi_token = os.environ.get("PYPI_TOKEN") or os.environ.get("HANZO_PYPI_TOKEN")
if not pypi_token:
print_error("PYPI_TOKEN or HANZO_PYPI_TOKEN environment variable not set")
return False
# Upload to PyPI
result = subprocess.run(
["python", "-m", "twine", "upload", "dist/*", "--skip-existing"],
cwd=package_dir,
env={**os.environ, "TWINE_USERNAME": "__token__", "TWINE_PASSWORD": pypi_token},
capture_output=True,
text=True,
)
if result.returncode != 0:
print_error(f"Upload failed: {result.stderr}")
return False
print_info(f"✅ Successfully published {package_name}")
return True
def check_and_publish_package(package_name: str, package_dir: Path) -> bool:
"""Check version and publish if newer."""
print_info(f"Checking {package_name}...")
# Get local version
local_version = get_local_version(package_dir)
if not local_version:
print_warn(f"Could not find version for {package_name}")
return False
print_info(f" Local version: {local_version}")
# Get PyPI version
pypi_version = get_pypi_version(package_name)
if pypi_version:
print_info(f" PyPI version: {pypi_version}")
else:
print_info(f" PyPI version: Not published yet")
# Check if we need to publish
if is_newer_version(local_version, pypi_version):
print_action(f"📦 New version detected for {package_name}: {local_version}")
# Build the package
if not build_package(package_dir):
return False
# Publish to PyPI
return publish_package(package_dir, package_name)
else:
print_info(f" ✓ {package_name} is up to date")
return True
def main():
"""Main function to check and publish all packages."""
# Define packages in dependency order
packages = [
"hanzo-network",
"hanzo-memory",
"hanzo-agents",
"hanzo-aci",
"hanzo-mcp",
"hanzo-dev",
"hanzo",
]
# Get repository root
script_dir = Path(__file__).parent
repo_root = script_dir.parent
pkg_dir = repo_root / "pkg"
# Install required tools
print_info("Installing build tools...")
try:
# Try with pip first
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--quiet",
"--upgrade",
"pip",
"build",
"twine",
],
check=True,
capture_output=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
# Fall back to installing without pip upgrade
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "--quiet", "build", "twine"],
check=True,
capture_output=True,
)
except Exception:
print_warn("Could not install build tools. Make sure pip, build, and twine are available.")
print_info("You can install them with: pip install build twine")
# Track results
published = []
failed = []
skipped = []
# Check and publish each package
for package_name in packages:
package_dir = pkg_dir / package_name
if not package_dir.exists():
print_warn(f"Package directory {package_dir} does not exist")
skipped.append(package_name)
continue
try:
local_version = get_local_version(package_dir)
pypi_version = get_pypi_version(package_name)
if is_newer_version(local_version, pypi_version):
if check_and_publish_package(package_name, package_dir):
published.append(f"{package_name} ({local_version})")
else:
failed.append(package_name)
else:
skipped.append(f"{package_name} (already at {local_version})")
except Exception as e:
print_error(f"Error processing {package_name}: {e}")
failed.append(package_name)
# Print summary
print("\n" + "=" * 60)
print("📊 SUMMARY")
print("=" * 60)
if published:
print(f"\n{GREEN}✅ Published ({len(published)}):{NC}")
for pkg in published:
print(f" • {pkg}")
if skipped:
print(f"\n{BLUE}⏭️ Skipped ({len(skipped)}):{NC}")
for pkg in skipped:
print(f" • {pkg}")
if failed:
print(f"\n{RED}❌ Failed ({len(failed)}):{NC}")
for pkg in failed:
print(f" • {pkg}")
sys.exit(1)
# Set GitHub Actions output if running in CI
if os.environ.get("GITHUB_ACTIONS"):
if published:
print(
f"::notice title=Published Packages::Published {len(published)} packages to PyPI: {', '.join(published)}"
)
else:
print("::notice title=No Updates::All packages are up to date on PyPI")
print(f"\n{GREEN}✨ All packages processed successfully!{NC}")
return 0
if __name__ == "__main__":
sys.exit(main())
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# Comprehensive script to publish all Python packages to PyPI
# Usage: ./bin/publish-all-packages.sh [package-name]
# If no package name is provided, all packages will be published
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if PYPI_TOKEN is set
if [ -z "$PYPI_TOKEN" ]; then
print_error "PYPI_TOKEN environment variable is not set"
exit 1
fi
# Get the package to publish (if specified)
SPECIFIC_PACKAGE=$1
# List of all packages in order of dependency
PACKAGES=(
"hanzo-network"
"hanzo-memory"
"hanzo-agents"
"hanzo-aci"
"hanzo-mcp"
"hanzo-dev"
"hanzo"
)
# Function to get package version from pyproject.toml
get_version() {
local package=$1
local pyproject="pkg/$package/pyproject.toml"
if [ -f "$pyproject" ]; then
grep "^version = " "$pyproject" | sed 's/version = "\(.*\)"/\1/'
else
echo "unknown"
fi
}
# Function to check if package exists on PyPI
check_pypi() {
local package=$1
local version=$2
curl -s "https://pypi.org/pypi/$package/$version/json" > /dev/null 2>&1
return $?
}
# Function to build and publish a package
publish_package() {
local package=$1
local package_dir="pkg/$package"
if [ ! -d "$package_dir" ]; then
print_error "Package directory $package_dir does not exist"
return 1
fi
print_info "Processing package: $package"
# Get version
local version=$(get_version "$package")
print_info "Package version: $version"
# Check if already published
if check_pypi "$package" "$version"; then
print_warn "Package $package version $version already exists on PyPI, skipping..."
return 0
fi
cd "$package_dir"
# Clean previous builds
print_info "Cleaning previous builds..."
rm -rf dist/ build/ *.egg-info
# Build the package
print_info "Building package..."
python -m build
# Upload to PyPI
print_info "Uploading to PyPI..."
python -m twine upload dist/* --skip-existing
print_info "✅ Successfully published $package version $version"
cd - > /dev/null
}
# Main execution
main() {
print_info "Starting package publication process..."
# Install required tools
print_info "Installing build tools..."
python -m pip install --quiet --upgrade pip build twine
# If specific package is provided, publish only that
if [ -n "$SPECIFIC_PACKAGE" ]; then
if [[ " ${PACKAGES[@]} " =~ " ${SPECIFIC_PACKAGE} " ]]; then
publish_package "$SPECIFIC_PACKAGE"
else
print_error "Unknown package: $SPECIFIC_PACKAGE"
print_info "Available packages: ${PACKAGES[*]}"
exit 1
fi
else
# Publish all packages
print_info "Publishing all packages..."
for package in "${PACKAGES[@]}"; do
publish_package "$package" || {
print_error "Failed to publish $package"
exit 1
}
done
fi
print_info "🎉 All packages published successfully!"
}
# Run main function
main
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -eux
mkdir -p dist
rye build --clean
rye publish --yes --token=$PYPI_TOKEN
-2
View File
@@ -151,7 +151,6 @@ include = [
"/*.md",
"/mypy.ini",
"/noxfile.py",
"bin/*",
"examples/*",
"pkg/hanzoai/*",
"tests/*",
@@ -542,7 +541,6 @@ hanzo-tools-s3 = { workspace = true }
[tool.ruff.lint.extend-per-file-ignores]
# Allow print statements in scripts and examples
"bin/**.py" = ["T201", "T203"]
"scripts/**.py" = ["T201", "T203", "E722"]
"tests/**.py" = ["T201", "T203", "F401", "F841"]
"examples/**.py" = ["T201", "T203", "F841"]