11name : Publish Python SDK
22
3- # Pulls pre-built binaries from the matching GitHub release (built by
4- # release.yml) instead of rebuilding the Go suite. This guarantees the
5- # bytes shipped on PyPI are byte-identical to the bytes shipped on GitHub
6- # Releases, preserves the version stamping (`-X main.version=$TAG`) and
7- # the macOS ad-hoc codesign + xattr-strip done in release.yml.
8- #
9- # Triggered by `release: published` (after release.yml's tarballs exist).
10- #
11- # Branches whose name starts with `ci_` ALSO get a dry-run on every push.
12- # This is the permanent rehearsal path for iterating on the workflow itself
13- # without making a real release. The push run executes everything up to but
14- # not including `twine upload` (the `publish` and `test-install` jobs are
15- # gated to release events). Required naming convention — only `ci_*`
16- # branches trigger the rehearsal; other branches are ignored.
17- # See CONTRIBUTING.md → "Testing publish workflows" for the convention.
18-
193on :
204 release :
215 types : [published]
22- push :
23- branches :
24- - ' ci_*'
6+ workflow_dispatch :
7+ inputs :
8+ version :
9+ description : ' Version to publish (without leading v, e.g. 1.9.1)'
10+ required : true
11+ type : string
12+ prerelease :
13+ description : ' Treat as prerelease (skip publish; build only)'
14+ required : false
15+ type : boolean
16+ default : false
2517
2618permissions :
2719 contents : write
2820 id-token : write
2921
22+ env :
23+ PILOT_VERSION : ${{ inputs.version || github.event.release.tag_name }}
24+ PILOT_PRERELEASE : ${{ inputs.prerelease || github.event.release.prerelease }}
25+
3026jobs :
3127 test-sdk :
32- if : " !github.event.release.prerelease"
3328 runs-on : ubuntu-latest
3429 steps :
3530 - name : Checkout repository
3631 uses : actions/checkout@v4
3732
38- - name : Set up Python
39- uses : actions/setup-python@v5
40- with :
41- python-version : ' 3.11'
33+ - name : Set up Docker Buildx
34+ uses : docker/setup-buildx-action@v3
4235
43- # Run the Python SDK's own unit tests. The previous `make test-sdk`
44- # under tests/integration/ was a stale reference (the Makefile is
45- # gone) — kept that step's spirit (gate publish on SDK tests passing)
46- # but pointed it at the pytest suite that actually exists.
47- - name : Run Python SDK unit tests
48- working-directory : sdk/python
36+ - name : Run SDK integration tests
4937 run : |
50- pip install --upgrade pip
51- pip install -e .[dev]
52- pytest tests/ -v
38+ cd tests/integration
39+ make test-sdk
5340 timeout-minutes : 10
5441
5542 build-wheels :
@@ -59,195 +46,29 @@ jobs:
5946 include :
6047 - os : ubuntu-latest
6148 platform : linux
62- release_arch : amd64
6349 - os : macos-latest
6450 platform : macos
65- release_arch : arm64
6651 runs-on : ${{ matrix.os }}
6752 steps :
6853 - name : Checkout repository
6954 uses : actions/checkout@v4
7055
71- - name : Resolve version + tag ( release event or ci_* push)
56+ - name : Set version from release tag or workflow input
7257 shell : bash
73- env :
74- GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
7558 run : |
76- # Release event: use the tag of the release being published.
77- # ci_* push: rehearse against the LATEST existing release.
78- # Either way, strip the leading "v" to get the bare PEP 440 version
79- # used by pyproject.toml and wheel filenames; keep the full tag for
80- # downloading release artifacts.
81- if [ "${{ github.event_name }}" = "release" ]; then
82- TAG="${{ github.event.release.tag_name }}"
83- else
84- TAG=$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)
85- echo "ci_* push rehearsal: using latest release ${TAG} as test source"
86- fi
87- VERSION="${TAG#v}"
88- echo "version=$VERSION" >> $GITHUB_ENV
89- echo "release_tag=$TAG" >> $GITHUB_ENV
90- echo "Resolved: version=$VERSION, release_tag=$TAG"
91-
92- # Update pyproject.toml so the wheel + sdist carry the release
93- # version. This is the single source of truth for "what version
94- # is this SDK?" — and it matches the release tag exactly.
59+ VERSION="${PILOT_VERSION}"
60+ VERSION="${VERSION#v}"
61+ echo "version=$VERSION" >> $GITHUB_ENV
9562 cd sdk/python
9663 sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
9764 rm pyproject.toml.bak
65+ echo "SDK version: $VERSION"
9866
99- # Pull pre-built binaries from the GitHub release. Using the unauthenticated
100- # download URL avoids consuming gh API rate limits for public repos.
101- - name : Download release tarball
102- shell : bash
103- env :
104- GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
105- run : |
106- set -euo pipefail
107- ARCH="${{ matrix.release_arch }}"
108- # Map matrix.platform → GOOS naming used in the tarball filename.
109- case "${{ matrix.platform }}" in
110- linux) GOOS=linux ;;
111- macos) GOOS=darwin ;;
112- *) echo "unsupported platform: ${{ matrix.platform }}"; exit 1 ;;
113- esac
114- ARCHIVE="pilot-${GOOS}-${ARCH}.tar.gz"
115- mkdir -p /tmp/pilot-release
116- echo "Downloading ${ARCHIVE} from release ${release_tag}..."
117- gh release download "${release_tag}" \
118- --repo "${{ github.repository }}" \
119- --pattern "${ARCHIVE}" \
120- --pattern "checksums.txt" \
121- --dir /tmp/pilot-release
122-
123- # Verify SHA-256 against the release's checksums.txt.
124- cd /tmp/pilot-release
125- if [ -f checksums.txt ]; then
126- EXPECTED=$(grep " ${ARCHIVE}\$" checksums.txt | awk '{print $1}')
127- if [ -z "$EXPECTED" ]; then
128- echo "Warning: ${ARCHIVE} not in checksums.txt; skipping verification"
129- else
130- if command -v sha256sum >/dev/null 2>&1; then
131- ACTUAL=$(sha256sum "${ARCHIVE}" | awk '{print $1}')
132- else
133- ACTUAL=$(shasum -a 256 "${ARCHIVE}" | awk '{print $1}')
134- fi
135- if [ "$EXPECTED" != "$ACTUAL" ]; then
136- echo "Checksum mismatch for ${ARCHIVE}!" >&2
137- echo " expected: $EXPECTED" >&2
138- echo " actual: $ACTUAL" >&2
139- exit 1
140- fi
141- echo " Verified SHA-256: $ACTUAL"
142- fi
143- fi
144-
145- # The release tarball ships daemon, pilotctl, gateway, registry, beacon,
146- # rendezvous, nameserver, updater. The Python SDK only ships the four
147- # client-side binaries — registry/beacon/rendezvous/nameserver are
148- # server infrastructure not part of the user-facing wheel.
149- #
150- # The release uses bare names (`daemon`, `gateway`, `updater`); the SDK
151- # uses prefixed names (`pilot-daemon`, `pilot-gateway`, `pilot-updater`)
152- # to avoid colliding with system binaries on PATH. Rename here.
153- #
154- # libpilot is NOT in the release tarball — it's CGO-built in the next
155- # step using the same release tag for its version stamp.
156- - name : Layout binaries into pilotprotocol/bin/
157- shell : bash
158- env :
159- # Resolve GOOS at workflow-eval time so the run-script doesn't
160- # need its own case statement. bash 3.2 (macOS default) chokes
161- # on `case` inside `$(...)` command substitution.
162- GOOS : ${{ matrix.platform == 'macos' && 'darwin' || 'linux' }}
163- ARCH : ${{ matrix.release_arch }}
164- run : |
165- set -euo pipefail
166- ARCHIVE="pilot-${GOOS}-${ARCH}.tar.gz"
167-
168- BIN_DIR="sdk/python/pilotprotocol/bin"
169- rm -rf "$BIN_DIR" && mkdir -p "$BIN_DIR"
170- tar -xzf "/tmp/pilot-release/${ARCHIVE}" -C "$BIN_DIR"
171-
172- cd "$BIN_DIR"
173- # Drop infrastructure binaries — not shipped in the SDK.
174- rm -f registry beacon rendezvous nameserver
175-
176- # Rename to SDK convention.
177- [ -f daemon ] && mv daemon pilot-daemon
178- [ -f gateway ] && mv gateway pilot-gateway
179- [ -f updater ] && mv updater pilot-updater
180-
181- # .pilot-version marker — the seeder reads this to compare against
182- # whatever's installed at ~/.pilot/bin/. Always normalize to bare
183- # version (no leading "v") so it's symmetric with install.sh.
184- echo "${version}" > .pilot-version
185-
186- echo ""
187- echo "pilotprotocol/bin/ after release-tarball extraction:"
188- ls -lh
189- echo ""
190- # Sanity: confirm version stamp made it through (release.yml builds
191- # with -X main.version=$TAG; SDK shouldn't drop it). Run against
192- # the runner's native arch only since cross-arch tarballs cannot
193- # be exec'd here.
194- if [ "${{ matrix.platform }}" = "linux" ] && [ -x pilotctl ]; then
195- STAMPED=$(./pilotctl version 2>&1 | head -1)
196- echo "pilotctl version → ${STAMPED}"
197- case "$STAMPED" in
198- *"${release_tag}"*|*"${version}"*)
199- echo " ✓ version stamp matches release tag"
200- ;;
201- *)
202- echo " ✗ version stamp '${STAMPED}' does not contain '${release_tag}'!" >&2
203- echo " ✗ pilotctl version must report the release tag, not 'dev'." >&2
204- echo " ✗ release.yml is supposed to build with -X main.version=\$GITHUB_REF_NAME." >&2
205- exit 1
206- ;;
207- esac
208- fi
209-
210- # Build libpilot.{so,dylib} — the CGO shared library used by the SDK
211- # via ctypes. Stamped with the same release tag (`-X main.version=$TAG`)
212- # so SDK provenance is consistent with the executables pulled from
213- # the release tarball. Codesigned ad-hoc on darwin (mirrors release.yml
214- # for the executables).
21567 - name : Set up Go
21668 uses : actions/setup-go@v5
21769 with :
21870 go-version-file : go.mod
21971
220- - name : Build libpilot CGO shared library
221- shell : bash
222- env :
223- GOOS : ${{ matrix.platform == 'macos' && 'darwin' || 'linux' }}
224- GOARCH : ${{ matrix.release_arch }}
225- CGO_ENABLED : ' 1'
226- run : |
227- set -euo pipefail
228- case "$GOOS" in
229- linux) EXT=so ;;
230- darwin) EXT=dylib ;;
231- esac
232- BIN_DIR="sdk/python/pilotprotocol/bin"
233- LDFLAGS="-s -w -X main.version=${release_tag}"
234- echo "Building libpilot.$EXT for ${GOOS}/${GOARCH}, version=${release_tag}..."
235- go build -buildmode=c-shared -ldflags "$LDFLAGS" \
236- -o "${BIN_DIR}/libpilot.${EXT}" ./sdk/cgo
237- # Drop the auto-generated header — SDKs load via ctypes by symbol,
238- # not against the C header.
239- rm -f "${BIN_DIR}/libpilot.h"
240-
241- if [ "$GOOS" = "darwin" ]; then
242- echo "Ad-hoc codesigning libpilot.${EXT}..."
243- codesign --force --sign - "${BIN_DIR}/libpilot.${EXT}"
244- xattr -cr "${BIN_DIR}/libpilot.${EXT}" || true
245- fi
246-
247- echo ""
248- echo "Final pilotprotocol/bin/ contents (post-libpilot):"
249- ls -lh "$BIN_DIR"
250-
25172 - name : Set up Python
25273 uses : actions/setup-python@v5
25374 with :
@@ -263,12 +84,9 @@ jobs:
26384
26485 - name : Build wheel
26586 shell : bash
266- env :
267- PILOT_SKIP_BUILD_BINARIES : ' 1'
268- SKIP_TWINE_CHECK : ' 1'
26987 run : |
27088 cd sdk/python
271- chmod +x scripts/build.sh
89+ chmod +x scripts/build-binaries.sh scripts/build .sh
27290 ./scripts/build.sh
27391
27492 - name : Convert to manylinux wheel (Linux only)
@@ -309,11 +127,8 @@ jobs:
309127 retention-days : 7
310128
311129 publish :
312- # Real publishes only fire on `release: published`. ci_* push rehearsals
313- # run everything up through wheel-build + version-stamp checks, then stop
314- # here so no test wheel reaches PyPI.
315- if : github.event_name == 'release'
316130 needs : build-wheels
131+ if : ${{ !(inputs.prerelease || github.event.release.prerelease) }}
317132 runs-on : ubuntu-latest
318133 steps :
319134 - name : Download all artifacts
@@ -374,7 +189,6 @@ jobs:
374189 echo "**Version:** ${{ steps.extract-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
375190 echo "**Install:** \`pip install pilotprotocol\`" >> $GITHUB_STEP_SUMMARY
376191 echo "**PyPI:** https://pypi.org/project/pilotprotocol/" >> $GITHUB_STEP_SUMMARY
377- echo "**Source:** Pulled binaries from release \`${{ github.event.release.tag_name }}\` (no rebuild)" >> $GITHUB_STEP_SUMMARY
378192
379193 test-install :
380194 needs : publish
@@ -391,38 +205,8 @@ jobs:
391205 - name : Wait for PyPI propagation
392206 run : sleep 60
393207
394- - name : Resolve expected version from release tag
395- shell : bash
396- run : |
397- TAG="${{ github.event.release.tag_name }}"
398- VERSION="${TAG#v}"
399- echo "version=$VERSION" >> $GITHUB_ENV
400- echo "release_tag=$TAG" >> $GITHUB_ENV
401-
402- - name : Install and verify version stamp matches release
403- shell : bash
208+ - name : Install and verify
404209 run : |
405- set -euo pipefail
406- pip install --no-cache-dir pilotprotocol=="${version}"
407-
408- # Smoke-load the FFI module — confirms libpilot loads on this host.
409- python -c "from pilotprotocol import Driver; print('SDK module imports OK')"
410-
411- # Assert pilotctl reports the actual release tag (not 'dev').
412- # This is the regression test for the reported issue: pip-installed
413- # pilotprotocol must run pilotctl with the release version stamped in.
414- STAMPED=$(pilotctl version 2>&1 | head -1)
415- echo "pilotctl version → ${STAMPED}"
416- case "$STAMPED" in
417- *"${release_tag}"*|*"${version}"*)
418- echo "✓ pilotctl version reports the release tag"
419- ;;
420- *dev*|*unknown*|"")
421- echo "✗ pilotctl version reports '${STAMPED}' — looks unstamped." >&2
422- exit 1
423- ;;
424- *)
425- echo "✗ pilotctl version reports '${STAMPED}', expected to contain '${release_tag}' or '${version}'." >&2
426- exit 1
427- ;;
428- esac
210+ pip install pilotprotocol
211+ pilotctl info 2>&1 | head -1 || true
212+ python -c "from pilotprotocol import Driver; print('SDK installed')"
0 commit comments