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+
319on :
420 release :
521 types : [published]
22+ push :
23+ branches :
24+ - ' ci_*'
625
726permissions :
827 contents : write
@@ -16,13 +35,21 @@ jobs:
1635 - name : Checkout repository
1736 uses : actions/checkout@v4
1837
19- - name : Set up Docker Buildx
20- uses : docker/setup-buildx-action@v3
38+ - name : Set up Python
39+ uses : actions/setup-python@v5
40+ with :
41+ python-version : ' 3.11'
2142
22- - name : Run SDK integration tests
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
2349 run : |
24- cd tests/integration
25- make test-sdk
50+ pip install --upgrade pip
51+ pip install -e .[dev]
52+ pytest tests/ -v
2653 timeout-minutes : 10
2754
2855 build-wheels :
@@ -32,29 +59,195 @@ jobs:
3259 include :
3360 - os : ubuntu-latest
3461 platform : linux
62+ release_arch : amd64
3563 - os : macos-latest
3664 platform : macos
65+ release_arch : arm64
3766 runs-on : ${{ matrix.os }}
3867 steps :
3968 - name : Checkout repository
4069 uses : actions/checkout@v4
4170
42- - name : Set version from release tag
71+ - name : Resolve version + tag ( release event or ci_* push)
4372 shell : bash
73+ env :
74+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
4475 run : |
45- VERSION="${{ github.event.release.tag_name }}"
46- VERSION="${VERSION#v}"
47- echo "version=$VERSION" >> $GITHUB_ENV
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.
4895 cd sdk/python
4996 sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
5097 rm pyproject.toml.bak
51- echo "SDK version: $VERSION"
5298
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).
53215 - name : Set up Go
54216 uses : actions/setup-go@v5
55217 with :
56218 go-version-file : go.mod
57219
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+
58251 - name : Set up Python
59252 uses : actions/setup-python@v5
60253 with :
@@ -70,9 +263,12 @@ jobs:
70263
71264 - name : Build wheel
72265 shell : bash
266+ env :
267+ PILOT_SKIP_BUILD_BINARIES : ' 1'
268+ SKIP_TWINE_CHECK : ' 1'
73269 run : |
74270 cd sdk/python
75- chmod +x scripts/build-binaries.sh scripts/build .sh
271+ chmod +x scripts/build.sh
76272 ./scripts/build.sh
77273
78274 - name : Convert to manylinux wheel (Linux only)
@@ -113,6 +309,10 @@ jobs:
113309 retention-days : 7
114310
115311 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'
116316 needs : build-wheels
117317 runs-on : ubuntu-latest
118318 steps :
@@ -174,6 +374,7 @@ jobs:
174374 echo "**Version:** ${{ steps.extract-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
175375 echo "**Install:** \`pip install pilotprotocol\`" >> $GITHUB_STEP_SUMMARY
176376 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
177378
178379 test-install :
179380 needs : publish
@@ -190,8 +391,38 @@ jobs:
190391 - name : Wait for PyPI propagation
191392 run : sleep 60
192393
193- - name : Install and verify
394+ - name : Resolve expected version from release tag
395+ shell : bash
194396 run : |
195- pip install pilotprotocol
196- pilotctl info 2>&1 | head -1 || true
197- python -c "from pilotprotocol import Driver; print('SDK installed')"
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
404+ 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
0 commit comments