Skip to content

feat(bindings): build, test and publish the Swift package, the Android library and the Python wheels from a release - #496

Merged
bahdotsh merged 6 commits into
mainfrom
feat/release-native-packages
Oct 1, 2026
Merged

bahdotsh merged 6 commits into
mainfrom
feat/release-native-packages

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

What this does

A release now builds, tests and publishes the three bindings that were built and tested on every pull request but shipped nowhere: the Swift package, the Android library and the Python wheels.

Built and tested from the release's own binaries, before anything publishes. A failure in any of these stops npm and crates.io too.

  • swift-package: the XCFramework the package links is built in build-ios from the gated device and simulator slices and zipped once; the package is assembled around it and runs every suite, the bridge's suites and the consumer check on a simulator (scripts/test-swift-package.sh, the same script the pull-request job now runs). The manifest a consumer will resolve (real url, real checksum) is parsed by swift package dump-package.
  • android-library: built from the release's four ABIs and the regenerated Kotlin, with the pull-request checks plus --require-natives, lint, the test-count check (scripts/check_android_test_results.py, shared with ci.yml) and the minified consumer build. Writes an unsigned Maven repository.
  • python-package + test-python-wheels: one wheel per desktop library, installed by pip and run through the whole suite on macOS, Linux x86_64, Linux aarch64 and Windows.

On the GitHub release: the wheels, offline-protocol-X.Y.Z-swiftpm-xcframework.zip and offline-protocol-X.Y.Z-swift-package.tar.gz, all under SHA256SUMS.txt and the attestation.

Published, each behind a repository variable:

Channel Switch Credentials Dry run
Maven Central (com.offlineprotocol:offline-protocol-android) MAVEN_CENTRAL_PUBLISH MAVEN_CENTRAL_USERNAME/PASSWORD, MAVEN_SIGNING_KEY[_PASSWORD] uploads a deployment the Portal validates, then drops it
PyPI (offline-protocol-sdk) PYPI_PUBLISH none: trusted publishing, environment pypi metadata check only
Swift package none here none here assembles and parses the manifest

The Swift package is pulled, not pushed. The enterprise policy disables deploy keys on Offline-Protocol/offline-protocol-swift, and nothing in this repository should hold a credential for another one anyway. So the release attaches the assembled package and the archive its manifest names, and the distribution repository's own workflow (already in place, currently disabled) downloads the two, verifies their attestations against this repository's release.yml, checks out scripts/publish-swift-package.sh from the release tag and tags itself with its own token. gh workflow run publish.yml -R Offline-Protocol/offline-protocol-swift -f version=X.Y.Z after a release, or its six-hourly schedule.

Three bugs found on the way

  • Every wheel was py3-none-any. Four wheels with one file name and a different native library inside; pip would have installed a Windows DLL on Linux without a word. Each wheel is now retagged for its platform. The Linux tag is read from the library (manylinux_2_N for the newest glibc symbol it needs) rather than written down: the old manylinux_2_17 claim was never checked, and the library carries the floor of the image that built it.
  • import offline_protocol_sdk failed on every Windows install. pyproject.toml has never installed bless on Windows, where it has no backend, and ble_peripheral imported it at module level on the package's way in. The import is guarded; BlePeripheral.is_available() is False there, which start() already required. Caught by the first run of the suite against a Windows wheel, in this PR's CI.
  • A release candidate would have taken the release's number on PyPI. pyproject.toml carries the release core, so v0.28.0-rc.1 built offline_protocol_sdk-0.28.0, which PyPI never lets that release have afterwards. The wheel is now numbered from the tag in Python's spelling (scripts/pep440-version.sh: 0.28.0rc1); a suffix with no agreed spelling fails the version gate before anything builds.

What cannot be undone, and what refuses

  • A Swift tag never moves. publish-swift-package.sh accepts a version already tagged only when the tag holds exactly what it would publish (a re-run), and refuses one that differs. The workflow directory survives each tree replacement and is excluded from that comparison.
  • A version on Maven Central or PyPI is permanent. Both uploads skip a version already there, so a re-run of a shipped release stays green.
  • The signing key never enters Gradle: the library is signed in the publishing job with gpg (scripts/maven-central-bundle.sh), and the bundle refuses a file it does not expect, a missing javadoc or sources jar, or a second version.

Verification

  • actionlint clean on ci.yml, release.yml and the distribution workflow; shellcheck clean on every script.
  • scripts/tests/test-package-release-assets.sh (now 12 assets, 15 negative controls), test-publish-swift-package.sh (first publish, re-run, moved-tag refusal, rehearsal, six refusals each asserting its reason), test-pep440-version.sh, test-assemble-swift-package.sh, test-generate-bindings.sh, test_check_android_aar.py: all green locally.
  • scripts/tests/test-maven-central.sh (throwaway gpg key, stand-in Portal playing every deployment state): green on Ubuntu 24.04.
  • Mutation-checked: every guard in the packager, the publish script, the Maven bundle and upload scripts fails a test when removed.
  • A macOS wheel built by build-wheel.sh installed in a clean venv and passed the whole Python suite (801 tests) from inside tests/.
  • A fresh-context review of the diff found two defects that would have failed the first tag run, both fixed here: the Swift url helper read GITHUB_REPOSITORY, which inside the distribution repository names that repository, so its workflow would have refused every manifest (publish-swift-package.sh now takes --repository, and its test runs under the distribution repository's environment); and the Python suite's local-API tests use Unix sockets, which Windows lacks, so the Windows wheel test could never pass (the Unix-socket server skips on Windows, the TCP tests run there).
  • The Python suite had never run off Linux. ci.yml gains Python Wheel (macos-arm64, windows-x86_64): the library built, the wheel built and installed, the suite run from inside tests/, so the release's wheel test is not its first execution on either platform. This PR's CI is that first execution; expect a round on it.
  • Not run locally, covered by CI on this PR: test-build-wheel.sh (Linux-only: gcc and readelf), the refactored Swift simulator script, and the ubuntu-24.04-arm wheel leg.

Windows, after the first run of the suite there

767 of 770 tests passed on the Windows wheel the first time. Of the three that did not, one was the example: local_api_client.py --socket raised the NotImplementedError asyncio uses for a Unix socket on Windows and printed a traceback where a user should see one JSON line; it now does. Two are platform differences the suite now states:

  • test_a_retry_that_never_suspends_still_frees_the_manager[stop] is xfail on Windows: after a retried stop(), the manager is freed (its weakref is None) but the core still holds the file-store lock, so the next manager over the same directories is refused. close() releases the stores whatever still holds the core, and its variant passes. Not reproduced off a runner yet; a Windows caller that wants the stores back at once should close().
  • test_the_real_responder_is_built_lazily built the real zeroconf from synchronous code (so it bound to a thread of its own) and closed it on another loop, a wait that timed out once on the Windows runner and passed the time before. It now builds and closes on one loop, as the bridge does in start().
  • test_a_stalled_peer_queue_is_bounded is skipped on Windows: it wedges on a single 1 MiB frame, which Windows loopback absorbs despite the 8 KiB socket buffers, while the 2 MiB stall the restart test forms does wedge there. The queue bound it checks is the same code on every platform.

Not in this PR

  • The Swift package's public API is still what the React Native module happened to need (iOS storage providers internal). The distribution repository's workflow is disabled until that is chosen; nothing here publishes a Swift package until it is enabled.
  • The offline-protocol-service command defaults to a Unix socket, which asyncio does not provide on Windows; a Windows service needs --tcp today, and a one-line refusal for the default there is a small follow-up.
  • The one-time setup outside this repository: the Central Portal account and the com.offlineprotocol namespace, the signing key on keys.openpgp.org, the four MAVEN_* secrets, and the PyPI trusted publisher once the organisation exists. CONTRIBUTING.md lists it.
  • A workflow_dispatch dry run of release.yml from this branch, with version=0.27.0, is the rehearsal to run once the three new jobs are green here.

…d library and the Python wheels from a release

A release builds each from its own binaries and tests it before anything
publishes: the Swift package on a simulator with an application built
against it, the Android library with all four ABIs and a minified
application, each wheel installed by pip and run through the suite on its
platform. The wheels, the Swift package and the XCFramework its manifest
names go on the GitHub release. Maven Central and PyPI publish behind
repository variables; the Swift package is pulled by its distribution
repository's own workflow, which verifies the release's attestations and
tags itself with its own token, so no credential for it exists here.

Two defects on the way: every wheel was py3-none-any (four files, one
name, four libraries), and a release candidate would have taken the
release's number on PyPI. The Linux tag is now read from the library's
glibc floor and the wheel is numbered from the tag in Python's spelling.
A bare repository's HEAD points at git's configured default branch, which
is master where nothing set it, as on the runner. The test pushed the
workflow to main, and the clone that edits it then checked out nothing.
pyproject.toml has never installed bless on Windows, where it has no
backend, and ble_peripheral imported it at module level on the package's
way in, so the import failed on every Windows install. Guard the import;
is_available() is False without it, as start() already requires. Found by
the first run of the Python suite against a Windows wheel.
… one line, and the suite states two Windows differences

local_api_client.py --socket raised the NotImplementedError asyncio uses
for a Unix socket on Windows and printed a traceback where a user should
see one JSON line; it now does, pointing at --tcp.

Two tests state what the first run of the suite on Windows found. After a
retried stop(), the core, not the manager, still holds the file-store lock
there, and the next manager over the same directories is refused; close()
releases the stores and passes, so the stop variant is xfail on Windows
until it is reproduced off a runner. The stalled-peer bound test wedges on
a single 1 MiB frame, which Windows loopback absorbs despite 8 KiB socket
buffers; the 2 MiB stall its sibling forms does wedge, so it is skipped
there and the bound stays covered.
The lifecycle test built the real AsyncZeroconf from synchronous code,
which binds it to a thread of its own, and closed it inside asyncio.run,
on another loop: a wait that timed out once on a Windows runner. The
bridge builds it in start(), on the running loop; the test now does the
same.
The dry run of the release built them manylinux_2_34: the floor is glibc
2.34, which the old manylinux_2_17 tag never checked.
@bahdotsh
bahdotsh merged commit 284915a into main Oct 1, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant