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
126 changes: 113 additions & 13 deletions .github/workflows/native-artifacts.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# Producer for gitignored native binaries (Rust → xcframework), consumed by
# scripts/fetch-native-artifacts.sh (EAS eas-build-pre-install hook + CI).
# Producer for gitignored native binaries (Rust → xcframework / jniLibs),
# consumed by scripts/fetch-native-artifacts.sh (EAS eas-build-pre-install hook
# + CI).
#
# Model — the compiled xcframework is non-reproducible (embedded timestamps +
# Model — the compiled binaries are non-reproducible (embedded timestamps +
# build paths, see .gitignore), so CI is the SOLE builder/publisher: it builds,
# uploads the asset to R2 keyed by the Rust SOURCE fingerprint, and commits the
# refreshed checksum manifest + .artifact-key. That published asset therefore
# always matches the committed manifest the consumer verifies against.
# refreshed checksum manifest. That published asset therefore always matches the
# committed manifest the consumer verifies against.
#
# Idempotent: if the asset for the current source key already exists on R2, the
# expensive Rust/iOS build is skipped — reruns and non-Rust PR syncs are cheap.
# expensive Rust build is skipped — reruns and non-Rust PR syncs are cheap.
#
# ── Required config ───────────────────────────────────────────────────────────
# secrets.R2_ACCOUNT_ID Cloudflare account id (R2 S3 endpoint host)
# secrets.R2_ACCESS_KEY_ID R2 access key (Object Read & Write)
# secrets.R2_ACCESS_KEY_ID R2 access key (Object Read & Write, 32-char)
# secrets.R2_SECRET_ACCESS_KEY R2 secret key
# vars.R2_BUCKET bucket name, e.g. mortstack-native
# vars.R2_PUBLIC_BASE_URL public-read base, e.g. https://native.<zone>/mortstack
# vars.R2_PUBLIC_BASE_URL public-read base, e.g. https://pub-xxxx.r2.dev
name: Native artifacts

on:
Expand Down Expand Up @@ -55,7 +56,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
with:
# Push commits back to the PR/main branch, not a detached HEAD.
ref: ${{ github.head_ref || github.ref_name }}

- name: Compute artifact key
Expand Down Expand Up @@ -117,11 +117,8 @@ jobs:
--only-show-errors
echo "→ uploaded ${{ steps.key.outputs.asset }}"

# The build refreshes ios/*.sha256 + ios/Sources/ and we stamp the key.
# Committing them (only when changed) keeps the consumer's verify gate in
# lockstep with the asset just published.
- name: Commit refreshed manifest + key
if: steps.exists.outputs.found != 'true' && github.event_name != 'pull_request' || (steps.exists.outputs.found != 'true' && github.event.pull_request.head.repo.full_name == github.repository)
if: steps.exists.outputs.found != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
run: |
echo "${{ steps.key.outputs.key }}" > "$PKG/ios/.artifact-key"
git config user.name "github-actions[bot]"
Expand All @@ -134,5 +131,108 @@ jobs:
echo "nothing to commit"
else
git commit -m "chore(native): publish chat_mls_coreFFI ios (${{ steps.key.outputs.key }})"
git pull --rebase --autostash origin "${{ github.head_ref || github.ref_name }}" || true
git push
fi

android:
name: chat_mls_core (Android jniLibs)
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PKG: packages/chat-mls-core
R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.head_ref || github.ref_name }}

- name: Compute artifact key
id: key
run: |
KEY="$(bash scripts/artifact-key.sh "$PKG" android)"
echo "key=$KEY" >> "$GITHUB_OUTPUT"
echo "asset=chat_mls_core-android-$KEY.tar.gz" >> "$GITHUB_OUTPUT"

- name: Already published?
id: exists
run: |
URL="${R2_PUBLIC_BASE_URL%/}/${{ steps.key.outputs.asset }}"
if curl -fsI "$URL" >/dev/null 2>&1; then
echo "found=true" >> "$GITHUB_OUTPUT"
echo "✓ $URL already published — skipping build"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi

- name: Setup Java
if: steps.exists.outputs.found != 'true'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Setup Rust + Android targets
if: steps.exists.outputs.found != 'true'
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android

- name: Cache Rust build
if: steps.exists.outputs.found != 'true'
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/chat-mls-core

- name: Install cargo-ndk
if: steps.exists.outputs.found != 'true'
run: cargo install cargo-ndk --locked

- name: Build Android jniLibs
if: steps.exists.outputs.found != 'true'
run: |
# ubuntu-latest ships an Android NDK; build-mls.sh needs ANDROID_NDK_ROOT.
export ANDROID_NDK_ROOT="${ANDROID_NDK_ROOT:-${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_HOME}}"
echo "using NDK: $ANDROID_NDK_ROOT"
bash "$PKG/scripts/build-mls.sh" android

- name: Package
if: steps.exists.outputs.found != 'true'
run: |
tar czf "${{ steps.key.outputs.asset }}" \
-C "$PKG/android/src/main" jniLibs

- name: Upload to R2
if: steps.exists.outputs.found != 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
run: |
aws s3 cp "${{ steps.key.outputs.asset }}" \
"s3://${R2_BUCKET}/${{ steps.key.outputs.asset }}" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--only-show-errors
echo "→ uploaded ${{ steps.key.outputs.asset }}"

# Commits jniLibs.sha256 + the regenerated UniFFI Kotlin (java/uniffi) +
# its .sha256 + the key. Kept in lockstep with the published asset.
- name: Commit refreshed manifests + key
if: steps.exists.outputs.found != 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
run: |
echo "${{ steps.key.outputs.key }}" > "$PKG/android/src/main/.artifact-key"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$PKG/android/src/main/jniLibs.sha256" \
"$PKG/android/src/main/java/uniffi" \
"$PKG/android/src/main/.artifact-key"
if git diff --cached --quiet; then
echo "nothing to commit"
else
git commit -m "chore(native): publish chat_mls_core android (${{ steps.key.outputs.key }})"
git pull --rebase --autostash origin "${{ github.head_ref || github.ref_name }}" || true
git push
fi
1 change: 1 addition & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"preview": {
"distribution": "internal",
"android": { "buildType": "apk" },
"env": {
"R2_PUBLIC_BASE_URL": "https://pub-2ffb489eefe84de8b9b32e409fd30957.r2.dev"
}
Expand Down
74 changes: 38 additions & 36 deletions scripts/fetch-native-artifacts.sh
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
#!/usr/bin/env bash
# Restore a package's gitignored native binary from R2 and verify it against the
# committed checksum manifest. Runs as the EAS `eas-build-pre-install` hook (on
# Expo's builder, before `pod install`) and anywhere else the xcframework binary
# is needed but not in git.
# Restore a package's gitignored native binaries from R2 and verify them against
# the committed checksum manifest. Runs as the EAS `eas-build-pre-install` hook
# (on Expo's builder, before pod install / gradle) and anywhere else the
# binaries are needed but not in git.
#
# Contract (see docs/adr — non-reproducible binaries → CI is the sole builder):
# * The Mach-O binary inside chat_mls_coreFFI.xcframework is gitignored (too
# large + non-reproducible). Headers/plist/modulemap + the .sha256 manifest
# + .artifact-key ARE committed.
# * The producer workflow (native-artifacts.yml) builds the xcframework,
# uploads it to R2 keyed by the source fingerprint, and commits the
# refreshed manifest + .artifact-key. That published asset is what this
# script fetches, so asset manifest always match.
# Contract (non-reproducible binaries → CI is the sole builder):
# * The compiled binaries are gitignored (large + non-reproducible). Their
# containing dir's headers/metadata + the .sha256 manifest ARE committed.
# iOS → chat_mls_coreFFI.xcframework/**/chat_mls_coreFFI (the Mach-O)
# Android → android/src/main/jniLibs/*/libchat_mls_core.so
# * The producer workflow (native-artifacts.yml) builds them, uploads to R2
# keyed by the source fingerprint, and commits the refreshed manifest. So
# the published asset always matches the committed manifest verified here.
#
# Usage: scripts/fetch-native-artifacts.sh <ios|android>
#
# Env:
# R2_PUBLIC_BASE_URL (required) public-read base, e.g.
# https://native.example.com/mortstack — asset is
# fetched from "$R2_PUBLIC_BASE_URL/<asset>".
# R2_PUBLIC_BASE_URL (required on cache-miss) public-read base, e.g.
# https://pub-xxxx.r2.dev — asset fetched from
# "$R2_PUBLIC_BASE_URL/<asset>".
# ALLOW_BUILD=1 (optional) on cache-miss, compile from source instead
# of failing. For local dev only — the EAS builder has no
# Rust toolchain, so it must hit the fast fetch path.
# of failing. Local dev only — the EAS builder has no
# Rust/NDK toolchain, so it must hit the fetch path.
set -euo pipefail

TARGET="${1:?usage: fetch-native-artifacts.sh <ios|android>}"
Expand All @@ -30,38 +30,40 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

# ── Artifact registry ─────────────────────────────────────────────────────────
# Extend with new rows as android AAR / chat-crypto SignalFfi come online.
# Each row: a directory of gitignored binaries ($ART_DIR) under $DEST_DIR,
# verified against a committed $MANIFEST sitting next to it, published to R2 as
# <ASSET_PREFIX>-<source-key>.tar.gz (tar rooted at $DEST_DIR so it unpacks
# straight back into place).
case "$TARGET" in
ios)
PKG="packages/chat-mls-core"
DEST_DIR="$PKG/ios"
XCF="chat_mls_coreFFI.xcframework"
MANIFEST="$XCF.sha256" # relative to $DEST_DIR
ART_DIR="chat_mls_coreFFI.xcframework"
MANIFEST="chat_mls_coreFFI.xcframework.sha256"
ASSET_PREFIX="chat_mls_coreFFI-ios"
BUILD_SCRIPT_TARGET="ios"
;;
android)
# TODO: android jniLibs (*.so) are gitignored too — same trap, not yet
# published to R2. Soft no-op so android EAS builds aren't blocked by this
# hook until the AAR pipeline is wired. Wire it here when ready.
echo "native(android): AAR pipeline not yet published via R2 — skipping"
exit 0
PKG="packages/chat-mls-core"
DEST_DIR="$PKG/android/src/main"
ART_DIR="jniLibs"
MANIFEST="jniLibs.sha256"
ASSET_PREFIX="chat_mls_core-android"
;;
*)
echo "fetch-native-artifacts: unsupported target '$TARGET'" >&2
exit 2
;;
esac

# Verify the extracted tree against the committed manifest. hash_dir wrote paths
# relative to the xcframework dir, so we cd into it and point at ../<manifest>.
# hash_dir wrote manifest paths relative to $ART_DIR, so verify from inside it
# against ../<manifest>. cd-fail (dir absent) → non-zero → treated as "absent".
verify() {
( cd "$DEST_DIR/$XCF" && shasum -a 256 -c "../$MANIFEST" ) >/dev/null 2>&1
( cd "$DEST_DIR/$ART_DIR" 2>/dev/null && shasum -a 256 -c "../$MANIFEST" ) >/dev/null 2>&1
}

# Already present and valid (warm builder / re-run)? Nothing to do.
if [ -f "$DEST_DIR/$XCF/Info.plist" ] && verify; then
echo "native($TARGET): $XCF present + verified — skip"
# Already present + valid (warm builder / re-run)? Nothing to do.
if verify; then
echo "native($TARGET): $ART_DIR present + verified — skip"
exit 0
fi

Expand All @@ -83,14 +85,14 @@ if fetch && verify; then
fi

# Extracted but checksum failed = corruption/tamper or stale asset ≠ manifest.
if [ -f "$DEST_DIR/$XCF/Info.plist" ] && ! verify; then
if [ -d "$DEST_DIR/$ART_DIR" ] && ! verify; then
echo "native($TARGET): CHECKSUM MISMATCH — $ASSET does not match committed $MANIFEST" >&2
fi

# ── Fallback ──────────────────────────────────────────────────────────────────
if [ "${ALLOW_BUILD:-0}" = "1" ]; then
echo "native($TARGET): asset missing/invalid — building from source (ALLOW_BUILD=1)"
pnpm --filter @repo/chat-mls-core "build:${BUILD_SCRIPT_TARGET}"
pnpm --filter @repo/chat-mls-core "build:${TARGET}"
verify && { echo "native($TARGET): built + verified"; exit 0; }
echo "native($TARGET): local build produced a tree that fails $MANIFEST" >&2
exit 1
Expand All @@ -102,8 +104,8 @@ native($TARGET): artifact not available.
key : $KEY (source fingerprint of $PKG)
base : ${R2_PUBLIC_BASE_URL:-<unset>}

The prebuilt xcframework for this source revision has not been published.
The prebuilt native artifact for this source revision has not been published.
→ Run the 'Native artifacts' workflow (Actions ▸ workflow_dispatch), or
→ re-run locally with ALLOW_BUILD=1 to compile from source (needs Rust + Xcode).
→ re-run locally with ALLOW_BUILD=1 to compile from source (needs Rust; Android also NDK).
EOF
exit 1
Loading