Repository navigation
Native FFI builds - #2023
Draft
spacebear21 wants to merge 9 commits into
Draft
Native FFI builds#2023spacebear21 wants to merge 9 commits into
spacebear21 wants to merge 9 commits into
Conversation
native_toolchain_rust links Android libraries with the NDK the consumer's Flutter project selects, and accepts r27, the first NDK with the API 35 compiler wrappers it calls. r27 still aligns 64-bit libraries to 4 KB, which Google Play no longer accepts for apps that target Android 15 or later. NDK r28 and later align to 16 KB by default. Pass -z max-page-size=16384 to the linker for the 64-bit Android targets. Linking payjoin-ffi with NDK r27 changes its LOAD alignment from 0x1000 to 0x4000, and with NDK r29 the library is byte-identical with or without the flag, so no version check is needed. 32-bit ARM has no 16 KB requirement and is left alone.
Dart's published crate builds with Rust 1.85.1, which its rust-toolchain.toml pins, but the jobs that build the rest of each release still used the 1.85.0 MSRV toolchain. pack-npm compiles the published wasm and build-wheel builds the published library and bindings, in the javascript and python devshells. pack-nuget generates its bindings, and build-nuget-native the NuGet libraries, with 1.85.0 from the runner. Move those two devshells, pack-nuget and build-nuget-native to 1.85.1, named once in the flake as release-version, so the NuGet libraries and their bindings build with the same toolchain. The devshells' test jobs now build with 1.85.1 too. rust.yml's 1.85.0 leg keeps the MSRV for the core crates, and the C# and Kotlin test jobs keep it for payjoin-ffi. payjoin-ffi now declares rust-version 1.85 like the other crates, so Cargo names the floor to anyone building below it. The dart devshell stays on 1.85.0. Dart publishes generated Dart source and the Rust crate, which consumers compile with the package's own 1.85.1 toolchain file. Its build hook runs rustup's 1.85.1, which sets LD_LIBRARY_PATH to that toolchain's libraries while cargo still finds the devshell's rustc first in PATH. A nix 1.85.1 rustc then loads rustup's librustc_driver of the same name and fails to find libz. The C# Windows test job installed 1.85.0 but selected `1.85`, which rustup resolves to the newest 1.85 release, 1.85.1, so it did not test the MSRV like the Linux and macOS jobs. It now names 1.85.0.
The Kotlin workflow compiles production bindings after its tests by calling generate_bindings.sh directly, and only test.sh put Cargo-recent.lock in place. CI still built that step from the lock, but only because a fresh checkout has no Cargo.lock for test.sh to restore on exit. Calling the script on its own, or after test.sh restored a different lockfile, resolves the dependency graph afresh. Have generate_bindings.sh use the maintained lockfile itself, as the C# script does. When test.sh has already put it in place, the call does nothing.
Each binding builds its native libraries in its own job, on its own
runner, with whatever toolchain that job sets up, so no shipped file
can be rebuilt bit for bit. Add one script that builds every shipped
target on Linux with one pinned toolchain:
contrib/native/build.sh <triple> <out-dir>
Nothing yet checks that build.sh produces the same bytes twice. Build each target twice, on separate runners. The second build runs from another checkout path, home and cargo home, all with a space in them, so any path that leaks into a library breaks the match, including one from a cache the toolchain keeps under the home directory. A compare job fails unless the second build matches the first one's hashes. Once they match, the compare job combines the two macOS slices with llvm-lipo into universal2-apple-darwin, the file Python ships. lipo output depends only on its inputs, so the derived file needs no second build. Every checked library is uploaded as one `payjoin-native` artifact, laid out by triple, for the bindings to package. The workflow runs in the daily bindings run and not on pull requests: two builds of eleven targets is 22 jobs, too many for every PR, so the scheduled run carries this check.
The Linux builds link against stubs derived from zig's macOS libSystem, not Apple's SDKs, so a symbol missing on the platform would link and then fail to load. Nothing has yet loaded these libraries on an Apple system. Add a macOS job that verifies the libraries and never produces any: * Xcode's linker resolves every import of each iOS and macOS library against the real SDK of its platform. * Each library, the universal2 one included, is renamed with install_name_tool and signed with codesign, as Flutter does when it wraps a dylib in a framework. * A small Flutter app runs a smoke test in the arm64 simulator. A stand-in build hook makes it bundle the Linux-built library, so the test goes through Flutter's own embed path with those exact bytes. The smoke test does not reuse the Dart unit tests, because those call helpers from the `_test-utils` feature, which the shipped library is built without.
The release shipped native libraries only inside the wheels and the NuGet package, and nothing in it came from the native workflow's checked double build. The release workflow now calls the native workflow once, and every publish job waits for it, so a library that does not reproduce or fails the Apple checks stops the release. The GitHub release job names each library payjoin_ffi-<version>-<target>.<ext>, attaches all 12, lists them in SHA256SUMS and attests them, so a consumer can check a library against the release without unpacking a package. VERIFYING.md explains how to check one, or rebuild it from the Linux devshell, iOS included, and compare.
The NuGet native assets were cross-built with their own toolchain: cargo-zigbuild and cargo-xwin from pip, clang-cl and llvm-lib from the runner image, Rust 1.85.0, and the `csharp` feature, which only the bindings generator needs but which changes the library's metadata hash. The wheel's libraries were built by build-wheel.sh itself: the Linux one with the python devshell's host cargo, so its glibc floor was whatever the runner's toolchain linked against, and the universal2 macOS one with cargo-zigbuild, which links with `-g` and needs a toolchain of its own. None of it followed the native build contract, so the shipped files could not be rebuilt bit for bit, and they were not the ones the native workflow had checked. Both now download the payjoin-native artifact of the same run: the wheels take the Linux and universal2 files, and the NuGet package takes one file per RID, which removes the six build-nuget-native jobs, the pip pins, the clang-cl symlinks and the cross branch of build_nuget_native.sh. The release and the daily run make python and csharp wait for native. The per-RID smoke tests still install the packed nupkg on each platform. build-wheel.sh now takes the library to bundle as its argument. It generates the bindings from a development build of the same featureless library, because uniffi reads its metadata from the static symbol table, which the zig-linked Linux library lacks. The Linux library needs at most glibc 2.17, so auditwheel tags the wheel manylinux_2_17, and the macOS wheel is tagged macosx_10_15_universal2, the contract's floor, as the changelog notes. The python devshell no longer cross-links anything, so it drops cargo-zigbuild and the macOS targets on Linux. Since the GitHub release lists each library on its own, the wheel build and the pack job compare every library inside the package with the native file and fail on any difference. They run before publishing, which the GitHub release job does not. With that check in place, SHA256SUMS no longer lists the NuGet members a second time, and generate-sha256sums.sh loses its archive option, which nothing else uses.
Every Dart consumer compiled payjoin-ffi with their own Rust toolchain, so none of them got the library the release checks for reproducibility, and every app build needed Rust and the targets' toolchains. prepare-publish.sh now copies the native workflow's payjoin-native artifact into the staged package as prebuilt/<target>/<file>, for the 11 targets the hook maps; the universal macOS library is left out. The release and the daily run make dart wait for native to get it. The 11 libraries add about 26 MiB to the compressed archive, well under pub.dev's 100 MiB limit. The build hook now has three modes, chosen by the `build` user define in the root package's pubspec: - prebuilt, the default, registers the packaged library for the target. The build needs no network, no GitHub and no Rust, so it works offline and is hermetic. pub checks the archive against the sha256 in the consumer's pubspec.lock, and the release attests the staged archive. A target without a library is a build error that says how to switch to source, never a silent source build. - source builds the shipped native/Cargo.lock with --locked, as consumers did until now. - workspace builds payjoin-ffi from the checkout with _test-utils, and is what the package's own pubspec sets. It replaces the presence of .cargo/config.local.toml as the development signal, so a copy of the package directory no longer becomes a development build by accident. Both kinds of library register as uniffi:payjoin_ffi. uniffi-dart's bindings bind every function with @Native(assetId:), so they resolve by that id and the file name does not matter. A prebuilt-linux job runs the consumer program against the staged package. Pull requests build no release libraries, so the Linux test job places the host library its tests built under prebuilt/ and runs the same check. The reproducibility job's consumer now selects source, which it exists to test. Because the libraries travel inside the package, publish-dart keeps its place beside the other publish jobs and the release still needs one approval. It now also attests the staged package archive the release attaches, as the other registries attest theirs.
3 tasks
Collaborator
Coverage Report for CI Build 41086680050Coverage remained the same at 87.547%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #2016
Combines phases 1 and 2 from #1997
Build every payjoin-ffi native library once per release, check that two builds of each are byte-identical, and ship those exact files through Python, C#, Dart and the GitHub release.
contrib/native/build.shbuilds the featureless library for 11 targets on Linux in a new.#nativedevshell, with Rust 1.85.1,Cargo-recent.lockand path remapping.contrib/native/README.mddocuments the rules.native.ymlbuilds each target twice from different paths and fails on any mismatch. A macOS job checks the Apple libraries against Xcode and runs an iOS simulator smoke test. Itruns daily and in releases, not on PRs.
SHA256SUMSand attestations.payjoin-ffi/VERIFYING.mdexplains how to check or rebuild them.build: sourcekeeps the previous behaviour.The first three commits are independent of the rest (Dart 16 KB alignment on NDK r27, Rust 1.85.1 for release bindings, Kotlin bindings from the lock) and will also be opened as small PRs.
Validated locally: every target reproduced across two builds with no build paths, the wheel and NuGet replays packed exactly those bytes, and the staged Dart package loaded its
library in prebuilt mode. The macOS job, the macOS and Windows smoke tests, release attestation and upload, and a Flutter app build in prebuilt mode run for the first time in CI.
Pull Request Checklist
Please confirm the following before requesting review: