Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 278 additions & 27 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,56 +1,307 @@
name: publish

# PILOT-203: PyPI publish workflow.
# PILOT-203 / release fan-out: PyPI publish workflow.
#
# Triggers on:
# - Release published (the normal path: tag a release on GitHub → publish)
# - workflow_dispatch (manual fallback when a release was created but
# publish missed it, or when republishing on a fresh PYPI_API_TOKEN)
# - workflow_dispatch with a `version` input — the canonical path. web4's
# release pipeline dispatches this at the daemon tag (e.g. 1.12.3) so the
# SDK publishes version-locked to the daemon, NOT to whatever stale
# version sits in pyproject.toml.
# - release published (legacy path, kept for manual GitHub releases in this
# repo). Uses the release tag as the version.
#
# The published wheel BUNDLES the native runtime (pilot-daemon, pilotctl,
# pilot-gateway, pilot-updater, libpilot.{so,dylib}) under pilotprotocol/bin/.
# Those binaries are built from source here because the standalone SDK repo
# carries no Go code: libpilot (the CGO shared lib) lives in
# pilot-protocol/libpilot and builds against a set of sibling Go modules
# (the same checkout set libpilot CI uses). Building from source keeps the
# wheel version-locked and reproducible.
#
# Required secret:
# PYPI_API_TOKEN — pypi.org token scoped to the pilotprotocol project.

on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (with or without leading v, e.g. 1.12.3)'
required: true
type: string
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

env:
PILOT_VERSION_RAW: ${{ inputs.version || github.event.release.tag_name }}

jobs:
build:
name: Build wheel + sdist
# Normalize the version once. `version` is bare (X.Y.Z) for package
# metadata; `ref` is the web4 git tag (always vX.Y.Z) for source checkout.
prep:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
ref: ${{ steps.v.outputs.ref }}
steps:
- id: v
shell: bash
run: |
RAW="${PILOT_VERSION_RAW}"
if [ -z "$RAW" ]; then
echo "::error::no version supplied (inputs.version / release tag both empty)"
exit 1
fi
BARE="${RAW#v}"
echo "version=$BARE" >> "$GITHUB_OUTPUT"
echo "ref=v$BARE" >> "$GITHUB_OUTPUT"
echo "version=$BARE ref=v$BARE"

build-wheels:
needs: prep
name: Build wheel (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
- os: macos-latest
platform: macos
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Checkout sdk-python
uses: actions/checkout@v4
with:
path: sdk-python

# libpilot + its sibling Go modules. go.mod in libpilot uses local
# `replace` directives pointing at ../<repo>, so every replaced module
# must be checked out as a sibling directory at the workspace root.
- name: Checkout libpilot
uses: actions/checkout@v4
with: { repository: pilot-protocol/libpilot, path: libpilot }
- name: Checkout web4
uses: actions/checkout@v4
with: { repository: pilot-protocol/pilotprotocol, path: web4, ref: "${{ needs.prep.outputs.ref }}" }
- name: Checkout common
uses: actions/checkout@v4
with: { repository: pilot-protocol/common, path: common }
- name: Checkout trustedagents
uses: actions/checkout@v4
with: { repository: pilot-protocol/trustedagents, path: trustedagents }
- name: Checkout handshake
uses: actions/checkout@v4
with: { repository: pilot-protocol/handshake, path: handshake }
- name: Checkout policy
uses: actions/checkout@v4
with: { repository: pilot-protocol/policy, path: policy }
- name: Checkout runtime
uses: actions/checkout@v4
with: { repository: pilot-protocol/runtime, path: runtime }
- name: Checkout skillinject
uses: actions/checkout@v4
with: { repository: pilot-protocol/skillinject, path: skillinject }
- name: Checkout webhook
uses: actions/checkout@v4
with: { repository: pilot-protocol/webhook, path: webhook }
- name: Checkout eventstream
uses: actions/checkout@v4
with: { repository: pilot-protocol/eventstream, path: eventstream }
- name: Checkout dataexchange
uses: actions/checkout@v4
with: { repository: pilot-protocol/dataexchange, path: dataexchange }
- name: Checkout updater
uses: actions/checkout@v4
with: { repository: pilot-protocol/updater, path: updater }
- name: Checkout gateway
uses: actions/checkout@v4
with: { repository: pilot-protocol/gateway, path: gateway }
- name: Checkout nameserver
uses: actions/checkout@v4
with: { repository: pilot-protocol/nameserver, path: nameserver }
- name: Checkout rendezvous
uses: actions/checkout@v4
with: { repository: pilot-protocol/rendezvous, path: rendezvous }
- name: Checkout beacon
uses: actions/checkout@v4
with: { repository: pilot-protocol/beacon, path: beacon }
- name: Checkout app-store
uses: actions/checkout@v4
with: { repository: pilot-protocol/app-store, path: app-store }

- uses: actions/setup-go@v5
with:
go-version-file: web4/go.mod

- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install --upgrade build twine
- run: python -m build
- run: python -m twine check dist/*
- uses: actions/upload-artifact@v4
python-version: '3.11'

- name: Pin version in pyproject.toml
shell: bash
working-directory: sdk-python
run: |
V="${{ needs.prep.outputs.version }}"
python - "$V" <<'PY'
import re, sys, pathlib
v = sys.argv[1]
p = pathlib.Path("pyproject.toml")
s = p.read_text()
s = re.sub(r'(?m)^version\s*=\s*".*?".*$', f'version = "{v}"', s, count=1)
p.write_text(s)
print("pyproject version ->", v)
PY
grep -m1 '^version' pyproject.toml

- name: Build native binaries (daemon, pilotctl, gateway, updater, libpilot)
shell: bash
run: |
set -euo pipefail
( cd libpilot && go mod tidy )

OS=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) echo "::error::unsupported arch $(uname -m)"; exit 1 ;;
esac
case "$OS" in
linux) EXT=so ;;
darwin) EXT=dylib ;;
*) echo "::error::unsupported os $OS"; exit 1 ;;
esac

OUT="$GITHUB_WORKSPACE/sdk-python/pilotprotocol/bin"
mkdir -p "$OUT"
LDFLAGS="-s -w -X main.version=${{ needs.prep.outputs.version }}"

echo "Building daemon/pilotctl/updater from web4..."
( cd web4 && CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$OUT/pilot-daemon" ./cmd/daemon )
( cd web4 && CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$OUT/pilotctl" ./cmd/pilotctl )
( cd web4 && CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$OUT/pilot-updater" ./cmd/updater )

echo "Building gateway..."
( cd gateway && go mod tidy && CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$OUT/pilot-gateway" ./cmd/gateway ) \
|| ( cd gateway && CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$OUT/pilot-gateway" . )

echo "Building libpilot CGO shared library..."
( cd libpilot && CGO_ENABLED=1 go build -buildmode=c-shared -ldflags "-s -w" -o "$OUT/libpilot.$EXT" . )

echo "${{ needs.prep.outputs.version }}" > "$OUT/.pilot-version"

if [ "$OS" = "darwin" ]; then
for b in "$OUT/pilot-daemon" "$OUT/pilotctl" "$OUT/pilot-gateway" "$OUT/pilot-updater" "$OUT/libpilot.$EXT"; do
codesign --force --deep --sign - "$b" || true
xattr -cr "$b" || true
done
fi

echo "Bundled binaries:"
ls -lh "$OUT"

- name: Build wheel + sdist
shell: bash
working-directory: sdk-python
run: |
set -euo pipefail
python -m pip install --upgrade pip build twine
cat > setup.py <<'EOF'
from setuptools import setup
from setuptools.dist import Distribution
class BinaryDistribution(Distribution):
def has_ext_modules(self):
return True
setup(distclass=BinaryDistribution)
EOF
python -m build --wheel
python -m build --sdist
rm -f setup.py

- name: Repair to manylinux (Linux)
if: matrix.platform == 'linux'
shell: bash
working-directory: sdk-python
run: |
pip install auditwheel patchelf
for plat in manylinux_2_35_x86_64 manylinux_2_31_x86_64 manylinux_2_28_x86_64; do
if auditwheel repair dist/*-linux_x86_64.whl --plat "$plat" -w dist/ 2>/dev/null; then
echo "repaired to $plat"; break
fi
done
rm -f dist/*-linux_x86_64.whl || true

- name: Verify
shell: bash
working-directory: sdk-python
run: python -m twine check dist/*

- name: Upload wheel
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.platform }}
path: sdk-python/dist/*.whl
retention-days: 7

- name: Upload sdist (Linux only)
if: matrix.platform == 'linux'
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
name: dist-sdist
path: sdk-python/dist/*.tar.gz
retention-days: 7

publish:
name: Publish to PyPI
needs: build
needs: build-wheels
runs-on: ubuntu-latest
permissions:
contents: read
# OIDC for trusted publisher (preferred). Falls back to API token
# when configured below.
id-token: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/setup-python@v5
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
python-version: '3.12'

- uses: actions/download-artifact@v4
with:
password: ${{ secrets.PYPI_API_TOKEN }}
verbose: true
path: dist-artifacts

- name: Collect dist
run: |
mkdir -p dist
find dist-artifacts -name '*.whl' -exec cp {} dist/ \;
find dist-artifacts -name '*.tar.gz' -exec cp {} dist/ \;
ls -lh dist/

- name: Resolve version + skip-if-exists
id: check
run: |
WHEEL=$(ls dist/*.whl | head -1)
VERSION=$(echo "$WHEEL" | sed -n 's/.*pilotprotocol-\([0-9][^-]*\)-.*/\1/p')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if curl -fsS "https://pypi.org/pypi/pilotprotocol/$VERSION/json" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::pilotprotocol $VERSION already on PyPI — skipping upload"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Publish to PyPI
if: steps.check.outputs.exists == 'false'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
# twine >= 6.1 understands Metadata-Version 2.4 (License-File),
# which modern setuptools emits; older system twine rejects it.
python -m pip install --upgrade "twine>=6.1"
python -m twine upload --non-interactive dist/*

- name: Summary
run: |
{
echo "## Python SDK"
echo "- Version: \`${{ steps.check.outputs.version }}\`"
echo "- Already present: \`${{ steps.check.outputs.exists }}\`"
echo "- Install: \`pip install pilotprotocol\`"
} >> "$GITHUB_STEP_SUMMARY"
Loading