ci: actually track the native workflows (.hanzo/ was gitignored)

The previous commit removed 13 GitHub workflows and added none, because
`.hanzo/` is in .gitignore here — so `git add .hanzo` silently did nothing and
the repo was left with no CI at all. `git status` reported the deletions
cleanly; only the missing additions gave it away.

`.hanzo/` is ignored on purpose (it also holds local agent scratch), so this
negates just the workflows path — the same shape hanzoai/universe already uses
to ignore `.hanzo/` while tracking its six workflow files.

Now landed: .hanzo/workflows/publish-pypi.yml (native, runs on
hanzo-build-linux-amd64) and .hanzo/workflows/sync-from-github.yml.
This commit is contained in:
2026-07-26 13:28:02 -07:00
parent 7aa204ed16
commit 2aebefb429
3 changed files with 185 additions and 0 deletions
+7
View File
@@ -37,6 +37,13 @@ QWEN.md
# Local databases and state
.hanzo/
# …except the native CI/CD workflows, which are source of truth on
# git.hanzo.ai. `.hanzo/` is ignored because it also holds local agent
# scratch; the workflows are not scratch. Without this negation a repo can
# delete its GitHub workflows and land NO replacement — which is exactly
# what happened here on the first attempt.
!.hanzo/workflows/
!.hanzo/workflows/**
.grok/
# Documentation build
+118
View File
@@ -0,0 +1,118 @@
name: Publish PyPI
# NATIVE CI. git.hanzo.ai is canonical; this runs on the Hanzo git-runner fleet
# (`hanzo-build-linux-amd64`). GitHub is a downstream mirror only.
#
# This replaces .github/workflows/publish-pypi.yml, which could not be run at
# all: dispatching it returns
# HTTP 422: Actions has been disabled for this user
# for the `hanzo-dev` account, and a tag push produced no run for the same
# reason. So `hanzo` 0.4.4 sat tagged and unpublished while PyPI kept serving
# 0.4.3, in which `hanzo auth login` cannot complete a login — Cloudflare
# rejects urllib's default User-Agent with `error code: 1010`, so the token
# exchange 403s AFTER the user has already signed in through the browser.
# Depending on GitHub to ship a fix for our own CLI was the actual defect.
#
# Tag -> package mapping is unchanged from the retired workflow, including the
# `hanzo-*` catch-all that maps `hanzo-v0.4.4` to the `hanzo` package.
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>}"
- name: Build and publish
env:
TWINE_USERNAME: __token__
# Two tokens cover per-project scope: PYPI_TOKEN owns most hanzo-*
# packages, HANZO_AI_PYPI_TOKEN the rest. Each upload tries the primary
# then falls back, so a 403 scope-miss on one is covered by the other.
PYPI_TOKEN_PRIMARY: ${{ secrets.PYPI_TOKEN }}
PYPI_TOKEN_FALLBACK: ${{ secrets.HANZO_AI_PYPI_TOKEN }}
run: |
set -uo pipefail
PACKAGES="${{ steps.pick.outputs.PACKAGES }}"
[ -z "$PACKAGES" ] && { echo "Nothing to publish."; exit 0; }
if [ -z "${PYPI_TOKEN_PRIMARY:-}" ] && [ -z "${PYPI_TOKEN_FALLBACK:-}" ]; then
echo "::error::No PyPI token on this forge. Add PYPI_TOKEN as a repo/org secret on git.hanzo.ai." >&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
+60
View File
@@ -0,0 +1,60 @@
name: Sync from GitHub
# git.hanzo.ai is CANONICAL and builds natively; development also lands on
# github.com/hanzoai/python-sdk. Together with the push-mirror going the other way
# (native -> GitHub, sync_on_commit) this is the full bidirectional loop.
#
# The two compose rather than fight: a native commit reaches GitHub via the
# push-mirror, so this job then sees LOCAL == REMOTE and exits "in sync". A
# GitHub commit fast-forwards native here, and the resulting push-mirror is a
# no-op because GitHub already has it. No echo, no loop.
#
# ONE deterministic direction per job: an in-cluster PULL. The runner reaches
# both ends (GitHub outbound, this forge via the instance URL actions/checkout
# already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY. A divergence fails LOUDLY here rather than force-pushing
# either side and destroying whichever history lost the race.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Checkout main (full history for the ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/python-sdk
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/python-sdk.git" main
LOCAL="$(git rev-parse HEAD)"
REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then
echo "in sync at $LOCAL"
exit 0
fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does NOT trigger other workflows
# (loop prevention), so synced commits would never build. Dispatch it
# explicitly — a real fast-forward means real commits arrived.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/deploy.yml/dispatches" \
-d '{"ref":"main"}' || echo "build dispatch failed (non-fatal)"
else
echo "DIVERGED: native $LOCAL is not an ancestor of GitHub $REMOTE." >&2
echo "Resolve by hand; this job will not force-push either side." >&2
exit 1
fi