Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Closes #
- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/) (`<type>(<scope>): <subject>`)
- [ ] Docs / `CHANGELOG.md` updated where relevant
- [ ] No new `unsafe` in core crates (only the `offline-protocol-uniffi` FFI boundary may use it, with SAFETY comments)
- [ ] If the UniFFI UDL changed, bindings were regenerated (`./scripts/generate-bindings.sh`) and **all three** — Swift, Kotlin, Python — committed together; a partial set fails at runtime, not build time
- [ ] If the UniFFI UDL changed, bindings were regenerated (`./scripts/generate-bindings.sh`) and **all four** — Swift, Kotlin, Python, Java — committed together; a partial set fails at runtime, not build time

## Breaking changes

Expand Down
65 changes: 58 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,49 @@ jobs:
shell: bash
run: bash scripts/check-c-library.sh windows-x86_64 target/debug

# The Java binding (docs/bridges/java.md). Its sources are generated from the
# UDL and their drift is gated with the other bindings (python-bindings). This
# is the other side: the generated Java, compiled and run against the
# library built from the same definition. The generated code verifies the
# library's FFI checksums before the first call, so the tests fail if the two
# were produced from different definitions. JDK 22 or later is required for
# the foreign function API; Gradle 8.9 cannot run on it, hence 9.x here.
java-binding:
name: Java Binding (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-java-binding

- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "25"

- uses: gradle/actions/setup-gradle@v6
with:
gradle-version: "9.3.1"

- name: Build the library
run: cargo build --locked --package offline-protocol-uniffi

# Compiles the committed generated sources, runs the consumer check
# against the library built above, and builds the Javadoc and sources jars,
# then writes the Maven repository the release signs, so a POM or
# publication error shows on the pull request and not on a tag.
- name: Build and test the Java binding
working-directory: bindings/java
run: gradle --no-daemon --stacktrace build publishAllPublicationsToStagingRepository

security:
name: Dependency Audit
runs-on: ubuntu-latest
Expand Down Expand Up @@ -629,6 +672,11 @@ jobs:
- name: Install uniffi-bindgen
run: cargo install uniffi --version 0.32.2 --features cli --locked --force

# Versioned apart from uniffi; the 0.5 series generates for UniFFI 0.32.
# scripts/generate-bindings.sh fails the build if the series disagrees.
- name: Install uniffi-bindgen-java
run: cargo install uniffi-bindgen-java --version 0.5.2 --locked --force

# Delete the generated files first, so the drift gate below proves they
# were *written* and not merely unchanged. `git diff --exit-code` on a
# path nothing wrote to reports no diff, which makes "up to date" and
Expand All @@ -645,6 +693,7 @@ jobs:
rm -rf bindings/react-native/ios/Generated \
bindings/react-native/android/src/main/java/uniffi
rm -f bindings/python/offline_protocol_sdk/offline_protocol.py
rm -rf bindings/java/src/main/java/com/offlineprotocol/sdk

# Regenerate before building anything. Bindgen reads the UDL and nothing
# else, so putting this ahead of the cdylib compile means drift surfaces
Expand All @@ -653,17 +702,18 @@ jobs:
- name: Regenerate all UniFFI bindings
run: bash scripts/generate-bindings.sh

# One regeneration, one gate: the three files are one artifact set off one
# One regeneration, one gate: the four sets are one artifact set off one
# UDL, so checking them separately would let a half-updated commit pass
# the half of the gate it happened to satisfy.
- name: Fail on stale bindings
run: |
if ! git diff --exit-code -- \
bindings/python/offline_protocol_sdk/offline_protocol.py \
bindings/react-native/ios/Generated/ \
bindings/react-native/android/src/main/java/uniffi/; then
bindings/react-native/android/src/main/java/uniffi/ \
bindings/java/src/main/java/com/offlineprotocol/sdk/; then
echo "::error::Committed UniFFI bindings are out of date."
echo "Run: bash scripts/generate-bindings.sh and commit all three."
echo "Run: bash scripts/generate-bindings.sh and commit all four."
exit 1
fi

Expand Down Expand Up @@ -752,12 +802,13 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.cargo/bin/uniffi-bindgen*
key: uniffi-bindgen-${{ runner.os }}-0.32.2
key: uniffi-bindgen-${{ runner.os }}-0.32.2-java-0.5.2

# --force: see the Python Bindings job's install step.
- name: Install uniffi-bindgen
- name: Install uniffi-bindgen and uniffi-bindgen-java
if: steps.cache-uniffi.outputs.cache-hit != 'true'
run: cargo install uniffi --version 0.32.2 --features cli --locked --force
run: |
cargo install uniffi --version 0.32.2 --features cli --locked --force
cargo install uniffi-bindgen-java --version 0.5.2 --locked --force

- name: Build the desktop library and the bindings
shell: bash
Expand Down
164 changes: 148 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ env:
# moved can already be in ~/.cargo/bin, and `cargo install` refuses to
# overwrite a binary it does not know it installed (see ci.yml).
UNIFFI_VERSION: "0.32.2"
# The community Java generator, versioned apart from uniffi. Its 0.5 series
# generates for UniFFI 0.32; scripts/generate-bindings.sh refuses another.
UNIFFI_BINDGEN_JAVA_VERSION: "0.5.2"

# ---------------------------------------------------------------------------
# Third-party action pinning
Expand Down Expand Up @@ -126,12 +129,14 @@ jobs:
id: cache-uniffi
uses: actions/cache@v6
with:
path: ~/.cargo/bin/uniffi-bindgen
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}
path: ~/.cargo/bin/uniffi-bindgen*
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}-java-${{ env.UNIFFI_BINDGEN_JAVA_VERSION }}

- name: Install uniffi-bindgen
- name: Install uniffi-bindgen and uniffi-bindgen-java
if: steps.cache-uniffi.outputs.cache-hit != 'true'
run: cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
run: |
cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
cargo install uniffi-bindgen-java --version ${{ env.UNIFFI_BINDGEN_JAVA_VERSION }} --locked --force

- name: Build iOS Libraries
working-directory: bindings/react-native
Expand Down Expand Up @@ -281,12 +286,14 @@ jobs:
id: cache-uniffi
uses: actions/cache@v6
with:
path: ~/.cargo/bin/uniffi-bindgen
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}
path: ~/.cargo/bin/uniffi-bindgen*
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}-java-${{ env.UNIFFI_BINDGEN_JAVA_VERSION }}

- name: Install uniffi-bindgen
- name: Install uniffi-bindgen and uniffi-bindgen-java
if: steps.cache-uniffi.outputs.cache-hit != 'true'
run: cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
run: |
cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
cargo install uniffi-bindgen-java --version ${{ env.UNIFFI_BINDGEN_JAVA_VERSION }} --locked --force

# The Kotlin this job uploads is downloaded *over* the committed Android
# bindings in the publish job, so it — not the committed file — is what
Expand Down Expand Up @@ -530,14 +537,16 @@ jobs:
id: cache-uniffi
uses: actions/cache@v6
with:
path: ~/.cargo/bin/uniffi-bindgen
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}
path: ~/.cargo/bin/uniffi-bindgen*
key: uniffi-bindgen-${{ runner.os }}-${{ env.UNIFFI_VERSION }}-java-${{ env.UNIFFI_BINDGEN_JAVA_VERSION }}

- name: Install uniffi-bindgen
- name: Install uniffi-bindgen and uniffi-bindgen-java
if: steps.cache-uniffi.outputs.cache-hit != 'true'
run: cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
run: |
cargo install uniffi --version ${{ env.UNIFFI_VERSION }} --features cli --locked --force
cargo install uniffi-bindgen-java --version ${{ env.UNIFFI_BINDGEN_JAVA_VERSION }} --locked --force

# Generates all three languages; only the Python file is uploaded here.
# Generates all four languages; only the Python file is uploaded here.
# Going through the shared script keeps the released wheel's bindings
# produced exactly the way CI's drift gate verified them, and picks up its
# check that uniffi-bindgen agrees with the crate's uniffi pin to the
Expand Down Expand Up @@ -787,6 +796,70 @@ jobs:
path: bindings/kotlin/offline-protocol-android/build/maven/
retention-days: 1

# The Java binding a release publishes: the committed generated sources
# (their drift is gated in ci.yml), compiled and tested against the release
# profile's native library on the JDK the foreign function API needs.
# Credential-free: it writes an unsigned Maven repository, and
# `publish-maven-java` signs what this tested. The jar holds no native code.
java-library:
name: Build Java Library
needs: [version-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: stable

- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "25"

# Gradle 8.9 cannot run on JDK 25 (ci.yml's java-binding job).
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
gradle-version: "9.3.1"

- name: Build the native library the tests load
run: cargo build --release --locked --package offline-protocol-uniffi

- name: Test the library and write it out as a Maven repository
working-directory: bindings/java
env:
VERSION: ${{ needs.version-gate.outputs.VERSION }}
run: >
gradle --no-daemon --stacktrace -PVERSION_NAME="$VERSION"
-PnativeLibrary="$GITHUB_WORKSPACE/target/release/liboffline_protocol_uniffi.so"
build publishAllPublicationsToStagingRepository

# Central refuses a deployment missing any of these, and the bundle script
# refuses anything else; failing here names the file before a signing key
# is involved.
- name: Check what is in the repository
env:
VERSION: ${{ needs.version-gate.outputs.VERSION }}
run: |
set -euo pipefail
DIR="bindings/java/build/maven/com/offlineprotocol/offline-protocol-java/$VERSION"
for suffix in .jar .pom .module -sources.jar -javadoc.jar; do
[ -s "$DIR/offline-protocol-java-$VERSION$suffix" ] || {
echo "::error title=Java library incomplete::offline-protocol-java-$VERSION$suffix is missing or empty."
exit 1
}
done
unzip -l "$DIR/offline-protocol-java-$VERSION.jar" | grep -q 'META-INF/LICENSE$' || {
echo "::error title=Java library incomplete::the jar does not carry the license."
exit 1
}

- uses: actions/upload-artifact@v7
with:
name: java-library-maven
path: bindings/java/build/maven/
retention-days: 1

# The Swift package a release publishes, built from the release's own
# libraries and tested on a simulator: its suites, the bridge's, and an
# application that depends on it. Assembled here with the XCFramework in
Expand Down Expand Up @@ -1043,11 +1116,11 @@ jobs:
# job alone, it splits the release across registries with no way back — see
# the long note on that job.
#
# The Swift package, the Android library and the wheels are in it too, and
# The Swift package, the Android and Java libraries and the wheels are in it too, and
# their tests: the GitHub release carries the wheels, the Swift package and
# the archive it names, and Maven Central and PyPI publish only after this
# job, so one that fails its tests stops everything before npm.
needs: [version-gate, build-ios, build-android, android-bindings, build-desktop-macos, build-desktop-linux, build-desktop-windows, python-bindings, test-python, audit-python, python-package, test-python-wheels, android-library, swift-package, test-c-library]
needs: [version-gate, build-ios, build-android, android-bindings, build-desktop-macos, build-desktop-linux, build-desktop-windows, python-bindings, test-python, audit-python, python-package, test-python-wheels, android-library, java-library, swift-package, test-c-library]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down Expand Up @@ -1733,7 +1806,8 @@ jobs:
# against the real Portal: the token, the signing key and every POM
# requirement are exercised, and nothing is released.
#
# MAVEN_CENTRAL_PUBLISH secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD,
# MAVEN_CENTRAL_PUBLISH both the Android and the Java library;
# secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD,
# MAVEN_SIGNING_KEY, MAVEN_SIGNING_KEY_PASSWORD
# PYPI_PUBLISH no secret: trusted publishing, environment `pypi`
#
Expand Down Expand Up @@ -1806,6 +1880,64 @@ jobs:
[ "$REAL_RELEASE" = true ] && MODE=publish
bash scripts/maven-central-upload.sh "$RUNNER_TEMP/central-bundle.zip" "$MODE" "$VERSION"

# com.offlineprotocol:offline-protocol-java, signed and uploaded as its own
# deployment, from the repository the `java-library` job built and tested,
# under the same switch as the Android library. Separate from `publish-maven`
# so that a refusal of one does not hold back the other; a version on Central
# is permanent, so a half-shipped release is re-run, not rolled back.
publish-maven-java:
name: Publish Java Library to Maven Central
needs: [version-gate, java-library, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version-gate.outputs.VERSION }}
REAL_RELEASE: ${{ needs.version-gate.outputs.REAL_RELEASE }}
MAVEN_ARTIFACT_ID: offline-protocol-java
MAVEN_PACKAGING: jar
steps:
- name: Channel off
if: vars.MAVEN_CENTRAL_PUBLISH != 'true'
run: echo "::notice title=Java library not published::MAVEN_CENTRAL_PUBLISH is not 'true', so the library was built and tested and not uploaded."

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: vars.MAVEN_CENTRAL_PUBLISH == 'true'

- name: Download the library
if: vars.MAVEN_CENTRAL_PUBLISH == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: java-library-maven
path: maven

- name: Sign and bundle
if: vars.MAVEN_CENTRAL_PUBLISH == 'true'
env:
SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }}
SIGNING_KEY_PASSWORD: ${{ secrets.MAVEN_SIGNING_KEY_PASSWORD }}
run: |
set -euo pipefail
[ -n "$SIGNING_KEY" ] || {
echo "::error title=Missing signing key::MAVEN_CENTRAL_PUBLISH is on, and the MAVEN_SIGNING_KEY secret is empty."
exit 1
}
bash scripts/maven-central-bundle.sh maven "$RUNNER_TEMP/central-bundle.zip" "$VERSION"

# A dry run rehearses, as for the Android library.
- name: Upload to Maven Central
if: vars.MAVEN_CENTRAL_PUBLISH == 'true'
env:
MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
run: |
set -euo pipefail
[ -n "$MAVEN_CENTRAL_USERNAME" ] && [ -n "$MAVEN_CENTRAL_PASSWORD" ] || {
echo "::error title=Missing Portal token::MAVEN_CENTRAL_PUBLISH is on, and MAVEN_CENTRAL_USERNAME or MAVEN_CENTRAL_PASSWORD is empty."
exit 1
}
MODE=rehearse
[ "$REAL_RELEASE" = true ] && MODE=publish
bash scripts/maven-central-upload.sh "$RUNNER_TEMP/central-bundle.zip" "$MODE" "$VERSION"

# offline-protocol-sdk on PyPI, by trusted publishing: PyPI trusts this
# workflow file and the `pypi` environment, so there is no token to store
# or leak. The wheels are the ones `test-python-wheels` installed and tested.
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Added

- **A Java binding.** `bindings/java` is a typed Java API generated from the
interface definition by `uniffi-bindgen-java`, the fourth member of the
artifact set that `scripts/generate-bindings.sh` writes with Swift, Kotlin
and Python. It reaches the native library through the Java foreign function
API, so it needs JDK 22 or later and brings no dependency: no JNA, no JNI,
no glue of its own, and it does not go through the C API. `OfflineProtocol`,
`MeshServices`, `DataStore`, every record and enum, the callback interfaces
a host implements (events, transports, storage providers) and a
`ProtocolException` subclass per engine refusal. The generated records have
no defaults, so a `ProtocolConfig` is spelled out in full. CI regenerates
and diffs the Java with the rest, and builds and tests it against the
library on Linux, macOS and Windows. The generator is versioned apart from UniFFI, so
the script refuses any series but 0.5. The jar is published to Maven
Central as `com.offlineprotocol:offline-protocol-java` by the release, beside
the Android library and under the same switch; it holds no native code, so the
host supplies the library from the release's assets. The contract is
`docs/bridges/java.md`.
- **A C API.** `bindings/c/include/offline_protocol.h` declares about a
dozen functions over the native library every release already ships:
create an engine from a JSON configuration, call any method of the
Expand Down
Loading
Loading