Skip to content

Commit 2efe049

Browse files
AlexgodorojaAlex Godoroja
andauthored
fix: improve node package structure (#99)
* fix: improve node package structure * fix some details * fix some details --------- Co-authored-by: Alex Godoroja <alex@vulturelabs.io>
1 parent f25f77b commit 2efe049

13 files changed

Lines changed: 977 additions & 142 deletions

File tree

.github/workflows/publish-node-sdk.yml

Lines changed: 505 additions & 80 deletions
Large diffs are not rendered by default.

.github/workflows/publish-python-sdk.yml

Lines changed: 246 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
name: 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+
319
on:
420
release:
521
types: [published]
22+
push:
23+
branches:
24+
- 'ci_*'
625

726
permissions:
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

CONTRIBUTING.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,37 @@ make test
136136
6. Ensure the project builds: `go build ./...`
137137
7. Submit a pull request with a clear description
138138

139+
### Testing publish workflows (`ci_*` branches)
140+
141+
`Publish Python SDK` and `Publish Node SDK` normally only fire on
142+
`release: published` events. Iterating on these workflows by cutting real
143+
releases is slow and side-effecting (every test wheel/npm package would
144+
be a permanent public artifact).
145+
146+
**The convention: branches whose name starts with `ci_` get a permanent
147+
dry-run on every push.** A push to `ci_*` runs everything through the
148+
build jobs, the version-stamp assertions, and the dry-pack — but stops
149+
before the actual `twine upload` / `npm publish` steps. The publish jobs
150+
are gated on `github.event_name == 'release'`, so a push rehearsal never
151+
touches PyPI or npm.
152+
153+
How it works:
154+
- On a release event, the workflows pull binaries from that release's tag.
155+
- On a `ci_*` push, they pull binaries from the **latest existing**
156+
release (resolved via `gh release view --json tagName`) and use that as
157+
the rehearsal target. This means: there must be at least one prior
158+
release on GitHub for the rehearsal to have something to pull from.
159+
160+
Naming:
161+
- `ci_workflow_iter` — fine
162+
- `ci_test_build_libpilot` — fine
163+
- `feature/ci-test`**does not match**; the rehearsal trigger pattern is
164+
`ci_*` literally. Any other branch name is silently ignored by the
165+
publish workflows.
166+
167+
Tip: use `gh run watch` after pushing to follow the run, and
168+
`gh run view --log-failed` to retrieve only failed-step logs.
169+
139170
### Code Style
140171

141172
- Follow standard Go conventions (`gofmt`, `go vet`)

sdk/node/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,10 @@ dist: binaries build
2929

3030
clean:
3131
rm -rf dist/ node_modules/ bin/ *.tgz
32+
# Per-platform sub-package bin/ trees (populated by build-binaries.sh
33+
# for local dev, by the publish workflow in CI). Wipe the contents but
34+
# keep the per-platform package.json files in place.
35+
find packages -mindepth 2 -maxdepth 3 -type f \
36+
\( -name 'pilot-*' -o -name 'pilotctl' -o -name 'libpilot.*' \) \
37+
-delete 2>/dev/null || true
38+
find packages -mindepth 2 -maxdepth 3 -type d -name bin -empty -delete 2>/dev/null || true

0 commit comments

Comments
 (0)