mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Release through the shared lane, not a copy of it
publish-pypi.yml was a per-repo spelling of a rule the whole estate needs, and it was the estate's only one: a Python repo without a copy of this file could not publish at all. `pypi:` in hanzo.yml now names what ships and hanzoai/ci ships it, so the rule has one home. The hardcoded package list goes with it. A glob over pkg/ cannot fail to notice a package added beside it, and the tag is matched against each pyproject's own name rather than against a case list, so all four tag shapes already in this repo's history resolve to the same distributions they did before. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+10
-15
@@ -1,24 +1,19 @@
|
||||
# ~7-line canonical caller — all real config lives in /hanzo.yml.
|
||||
#
|
||||
# This repo declared a gate and had nowhere to run it. `.github/workflows/cicd.yml`
|
||||
# imports the same pipeline, but its default label `linux-amd64` is
|
||||
# served on github.com by nothing: the ARC pool that last ran it there
|
||||
# (linux-amd64-lfpvh-runner-vt7lh, hanzoai/finance, 133s) was retired,
|
||||
# and every caller since has queued for 86402s — 24h exactly, GitHub's queue
|
||||
# timeout — and reported "cancelled". A gate that cannot be scheduled is not a
|
||||
# gate, and a red X that says "cancelled" reads like an infrastructure hiccup
|
||||
# rather than what it is: the suite never ran, and has not run for weeks.
|
||||
# The gate AND the release, one caller. `pypi:` in hanzo.yml names what this repo
|
||||
# publishes and hanzoai/ci publishes it, so the tags below are the release: a
|
||||
# `v*` releases whatever moved, `hanzo-mcp-v1.2.3` releases that one package.
|
||||
#
|
||||
# It replaces publish-pypi.yml, a per-repo copy of a rule the whole estate needs.
|
||||
# A second copy of a publish lane is a second place for it to be wrong — and the
|
||||
# one thing that copy could not do was notice a package added under pkg/ after it
|
||||
# was written, because its list of packages was written by hand.
|
||||
#
|
||||
# The pool that DOES serve that label is the git-runner fleet on git.hanzo.ai
|
||||
# (universe:infra/k8s/git-runner/statefulset.yaml), which is reachable only from
|
||||
# this plane — the same plane sync-from-github.yml already mirrors every GitHub
|
||||
# commit onto within ten minutes. So the gate belongs here, beside the publish
|
||||
# it guards, and this file is the ~7 lines that ask for it.
|
||||
name: CI/CD
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
tags: ['v*', 'hanzo-*', 'hanzoai-*']
|
||||
pull_request:
|
||||
# THE RELEASE POKES THE PROJECTION, and workflow_dispatch is how, because it
|
||||
# is the dispatch verb Hanzo Git HAS. Two shapes were tried and neither could
|
||||
@@ -62,7 +57,7 @@ on:
|
||||
default: ''
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v2
|
||||
with:
|
||||
spec-ref: ${{ github.event.inputs.spec-ref || '' }}
|
||||
spec-version: ${{ github.event.inputs.spec-version || '' }}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
name: Publish PyPI
|
||||
# Releases run on the Hanzo git-runner fleet (`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: [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 Hanzo Git 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
|
||||
@@ -1,17 +1,24 @@
|
||||
# Hanzo CI — the ONE way this repo is gated, read by hanzoai/ci.
|
||||
#
|
||||
# No `images:` and no `deploy:`: these are libraries. A consumer installs them
|
||||
# from PyPI at a tag, so what CI owes is a gate, not an artifact. Publishing
|
||||
# stays where it already is — .hanzo/workflows/publish-pypi.yml on our own
|
||||
# runners, which is the canonical path because it reads the PyPI token from KMS
|
||||
# like every other publish credential in the fleet. Adding a second publish here
|
||||
# would be one too many; this file only gates.
|
||||
# from PyPI at a tag, so what this repo delivers is a distribution, declared
|
||||
# below, and the rest of the file is the gate in front of it.
|
||||
#
|
||||
# Scope is the generated cloud client and the flows that exercise it. The other
|
||||
# 64 packages under pkg/ are hand-written and carry their own tests; pulling
|
||||
# them in here would make a red gate mean "something, somewhere" instead of
|
||||
# "the client the spec just produced is broken".
|
||||
|
||||
# The root project is `hanzoai`, the generated cloud client; everything under
|
||||
# pkg/ is a distribution of its own. A glob rather than 65 names, so a package
|
||||
# added under pkg/ publishes without anyone remembering to list it — which the
|
||||
# retired per-repo workflow's hardcoded lists could not do.
|
||||
#
|
||||
# A `v*` tag releases whatever moved (twine skips the versions already on the
|
||||
# index); `hanzo-mcp-v1.2.3` releases that one. hanzoai/ci reads the name out of
|
||||
# each pyproject, so the tag and the distribution cannot disagree.
|
||||
pypi: [., pkg/*]
|
||||
|
||||
test:
|
||||
- name: cloud-client
|
||||
# Import every generated module. For generated code this IS the build step:
|
||||
|
||||
Reference in New Issue
Block a user