Files
python-sdk/.hanzo/workflows/publish-pypi.yml
zeekay 80c31f3534 publish-pypi: read KMS at the route KMS serves
The job asked 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 token's own claim,
so every read 404'd and every release reported the credential unseeded —
including hanzo/prod/python-sdk-publish/PYPI_TOKEN, which is there.

Root to 3.2.13, README install line names it. hanzo-research declares its
readme, license and authors, so its page carries them.
2026-08-14 17:42:58 -07:00

157 lines
7.0 KiB
YAML

name: Publish PyPI
# Releases run on the Hanzo git-runner fleet (`hanzo-build-linux-amd64`) from
# git.hanzo.ai, which is canonical; GitHub is a downstream mirror. There is no
# .github counterpart to this file — one lane, one place.
#
# Tag -> package:
# v* every workspace package (twine --skip-existing makes the
# already-published ones no-ops)
# hanzoai-* the root `hanzoai` client alone
# hanzo-tools-* the tool packages
# hanzo-* the matching pkg/<name>, else `hanzo`
on:
workflow_dispatch:
inputs:
packages:
description: 'Packages to publish (space-separated, or "all")'
required: false
default: ''
push:
tags:
- 'v*'
- 'hanzo-*'
- 'hanzoai-*'
concurrency:
group: publish-pypi-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build and publish
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build tooling
run: python -m pip install --quiet --upgrade build twine
- name: Determine packages
id: pick
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
MANUAL="${{ github.event.inputs.packages }}"
# Auto-discovered so the list can never go stale — the retired
# workflow's hardcoded lists silently dropped newly added packages.
ALL="$( { echo hanzoai; ls -d pkg/*/ 2>/dev/null | xargs -n1 basename; } | sort -u | xargs)"
if [ -n "$MANUAL" ]; then
[ "$MANUAL" = "all" ] && PACKAGES="$ALL" || PACKAGES="$MANUAL"
elif [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
case "$TAG" in
hanzo-tools-*) PACKAGES="$(ls -d pkg/hanzo-tools-*/ 2>/dev/null | xargs -n1 basename | xargs)" ;;
hanzoai-*) PACKAGES="hanzoai" ;;
# Longest-match first, then the catch-all: `hanzo-v0.4.4` -> hanzo.
hanzo-*)
NAME="$(printf '%s' "$TAG" | sed -E 's/^(hanzo-[a-z0-9]+)-v?[0-9].*$/\1/')"
if [ "$NAME" != "$TAG" ] && [ -d "pkg/$NAME" ]; then PACKAGES="$NAME"; else PACKAGES="hanzo"; fi ;;
v*) PACKAGES="$ALL" ;;
*) PACKAGES="" ;;
esac
fi
echo "PACKAGES=$PACKAGES" >> "$GITHUB_OUTPUT"
echo "Publishing: ${PACKAGES:-<none>}"
# Secrets come from KMS only. The one credential this job is given is the
# KMS machine identity (KMS_CLIENT_ID / KMS_CLIENT_SECRET, a git.hanzo.ai
# Actions secret); the PyPI tokens are read at publish time and live
# nowhere else — not in Gitea, not in GitHub Actions secrets.
#
# KMS answers `GET /v1/kms/secrets/<path>/<name>?env=<env>` with
# `{"env","name","value"}`. The org is the token's own `owner` claim, so
# it is not in the URL, and the value is top level. Reading any other
# shape returns 404 for a secret that is present — which is
# indistinguishable from one that is absent, so keep this call in step
# with the KMS routes (kms-operator uses the same one).
- name: Load publish secrets from KMS
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
run: |
# FAIL, never exit 0. A publish pipeline that skips its upload and goes
# green is indistinguishable from a working one — hanzoai/extension lost
# v1.9.37 exactly that way, tagged and "successful" while npm stayed put.
if [ -z "$KMS_CLIENT_ID" ] || [ -z "$KMS_CLIENT_SECRET" ]; then
echo "::error::KMS machine identity absent — cannot pull PyPI creds. Seed KMS_CLIENT_ID/KMS_CLIENT_SECRET as git.hanzo.ai Actions secrets (org or repo scope)." >&2
exit 1
fi
KMS=https://kms.hanzo.ai; ENVN=prod; P=python-sdk-publish
TOKEN=$(curl -sf "$KMS/v1/kms/auth/login" -H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r .accessToken)
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] || { echo "::error::KMS login failed"; exit 1; }
# PYPI_TOKEN owns most hanzo-* packages; HANZO_AI_PYPI_TOKEN, when
# seeded, covers the rest. Each upload tries the primary then the
# fallback, so a 403 scope-miss on one is covered by the other.
#
# `|| true` on the fetch, and the status captured by hand: this step
# runs under `bash -e -o pipefail`, where `curl -sf` on an absent path
# exits 22 and ends the step before it can say why. Print the HTTP code
# instead, so "not seeded" and "wrong route" read differently.
for KEY in PYPI_TOKEN HANZO_AI_PYPI_TOKEN; do
BODY=$(curl -s -w '\n%{http_code}' "$KMS/v1/kms/secrets/$P/$KEY?env=$ENVN" \
-H "Authorization: Bearer $TOKEN") || true
CODE=$(printf '%s' "$BODY" | tail -1)
VAL=$(printf '%s' "$BODY" | sed '$d' | jq -r '.value // empty' 2>/dev/null || true)
if [ -n "$VAL" ]; then echo "::add-mask::$VAL"; echo "read $ENVN/$P/$KEY"; else
echo "note: KMS returned HTTP $CODE for $ENVN/$P/$KEY (no value)"
fi
echo "$KEY=$VAL" >> "$GITHUB_ENV"
done
- name: Build and publish
env:
TWINE_USERNAME: __token__
run: |
set -uo pipefail
PACKAGES="${{ steps.pick.outputs.PACKAGES }}"
[ -z "$PACKAGES" ] && { echo "Nothing to publish."; exit 0; }
PYPI_TOKEN_PRIMARY="${PYPI_TOKEN:-}"
PYPI_TOKEN_FALLBACK="${HANZO_AI_PYPI_TOKEN:-}"
if [ -z "$PYPI_TOKEN_PRIMARY" ] && [ -z "$PYPI_TOKEN_FALLBACK" ]; then
echo "::error::KMS holds no PyPI token at hanzo/prod/python-sdk-publish. Seed PYPI_TOKEN (and HANZO_AI_PYPI_TOKEN) there — not as a forge or GitHub Actions secret." >&2
exit 1
fi
rc=0
for package in $PACKAGES; do
if [ "$package" = "hanzoai" ]; then PKG_DIR="."
elif [ -d "pkg/$package" ]; then PKG_DIR="pkg/$package"
else echo "skip: no pkg/$package"; continue; fi
echo "── $package ($PKG_DIR)"
rm -rf "$PKG_DIR/dist" "$PKG_DIR/build"
python -m build "$PKG_DIR" --outdir "$PKG_DIR/dist" || { rc=1; continue; }
# --skip-existing keeps retries idempotent; try primary then fallback.
if ! TWINE_PASSWORD="$PYPI_TOKEN_PRIMARY" python -m twine upload "$PKG_DIR/dist/"* --skip-existing; then
if [ -n "${PYPI_TOKEN_FALLBACK:-}" ]; then
TWINE_PASSWORD="$PYPI_TOKEN_FALLBACK" python -m twine upload "$PKG_DIR/dist/"* --skip-existing || rc=1
else
rc=1
fi
fi
done
exit $rc