Skip to content

feat(expo): move biometric credentials to JS with @clerk/expo-biometrics #1018

feat(expo): move biometric credentials to JS with @clerk/expo-biometrics

feat(expo): move biometric credentials to JS with @clerk/expo-biometrics #1018

name: Expo
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- main
paths:
- '.github/workflows/expo-native-build.yml'
- 'integration/templates/expo-native/**'
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
- 'packages/expo-biometrics/**'
- 'packages/expo-google-signin/**'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: expo-native-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
FIXTURE_DIR: integration/templates/expo-native
E2E_DIR: integration/tests/expo-native
FIXTURE_PUBLISHABLE_KEY: pk_test_ZHVtbXkuY2xlcmsuYWNjb3VudHMuZGV2JA
SDK_PACK_DIR: /tmp/clerk-expo-pack
E2E_INSTANCE_NAME: clerkstage-with-native-components
BAPI_URL: https://api.clerkstage.dev
MAESTRO_VERSION: '2.10.0'
MAESTRO_CLI_NO_ANALYTICS: '1'
MAESTRO_SHARDS: '2'
MAESTRO_DRIVER_STARTUP_TIMEOUT: '180000'
jobs:
native-build:
if: ${{ github.head_ref != 'changeset-release/main' }}
name: Native ${{ matrix.run-e2e == true && 'E2E' || 'Build' }} (${{ matrix.platform }}, sdk ${{ matrix.expo-sdk }})
runs-on: ${{ matrix.runner }}
timeout-minutes: ${{ matrix.run-e2e == true && 60 || 45 }}
strategy:
fail-fast: false
matrix:
include:
- expo-sdk: 54
platform: android
runner: blacksmith-8vcpu-ubuntu-2204
- expo-sdk: 54
platform: ios
runner: blacksmith-6vcpu-macos-26
- expo-sdk: 55
platform: android
runner: blacksmith-8vcpu-ubuntu-2204
- expo-sdk: 55
platform: ios
runner: blacksmith-6vcpu-macos-26
- expo-sdk: 57
platform: android
runner: blacksmith-8vcpu-ubuntu-2204
run-e2e: true
- expo-sdk: 57
platform: ios
runner: blacksmith-6vcpu-macos-26
run-e2e: true
steps:
- name: Checkout repo
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Compute native build cache key
id: native-build-key
env:
PLATFORM: ${{ matrix.platform }}
run: |
build_hash="$(
git ls-files -s -- \
.npmrc \
package.json \
pnpm-lock.yaml \
pnpm-workspace.yaml \
tsconfig.json \
turbo.json \
packages/clerk-js \
packages/expo \
packages/expo-biometrics \
packages/expo-google-signin \
packages/react \
packages/shared \
"$FIXTURE_DIR" |
git hash-object --stdin
)"
if [ "$PLATFORM" = "android" ]; then
artifact="$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk"
else
artifact="$FIXTURE_DIR/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app"
fi
echo "hash=$build_hash" >> "$GITHUB_OUTPUT"
echo "artifact=$artifact" >> "$GITHUB_OUTPUT"
- name: Restore native build
id: native-build-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ steps.native-build-key.outputs.artifact }}
# Bump the version when native build commands change.
key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ matrix.run-e2e == true && 'e2e' || 'build' }}-${{ env.E2E_INSTANCE_NAME }}-${{ steps.native-build-key.outputs.hash }}
- if: steps.native-build-cache.outputs.cache-hit != 'true'
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- if: steps.native-build-cache.outputs.cache-hit != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24.15.0
cache: pnpm
- name: Install monorepo dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
- name: Build and pack Clerk packages
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: |
pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"
- name: Install fixture dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
env:
EXPO_SDK: ${{ matrix.expo-sdk }}
RUN_E2E: ${{ matrix.run-e2e == true && 'true' || 'false' }}
run: |
cp "package.sdk-$EXPO_SDK.json" package.json
pnpm install --no-frozen-lockfile
# [0-9] keeps this glob off the clerk-expo-google-signin tarball.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$BIOMETRICS_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
# flow on a blank screen. Skip it on e2e jobs only.
DEV_CLIENT="expo-dev-client"
if [ "$RUN_E2E" = "true" ]; then DEV_CLIENT=""; fi
pnpm expo install expo-auth-session expo-constants expo-crypto $DEV_CLIENT expo-secure-store expo-web-browser
if [ "$RUN_E2E" = "true" ]; then
REACT_VERSION=$(node -p 'require("react/package.json").version')
REACT_DOM_VERSION=$(node -p 'require("react-dom/package.json").version')
if [ "$REACT_VERSION" != "$REACT_DOM_VERSION" ]; then
echo "::error::React version mismatch: react@$REACT_VERSION, react-dom@$REACT_DOM_VERSION"
exit 1
fi
fi
- name: Check e2e secret availability
id: gate
if: matrix.run-e2e == true
env:
KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
run: |
if [ -n "$KEYS" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::notice::INTEGRATION_STAGING_INSTANCE_KEYS unavailable (fork PR?); running build-only."
fi
- name: Resolve Clerk instance keys
id: keys
if: steps.gate.outputs.ok == 'true'
continue-on-error: true
env:
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME"
- name: Write fixture .env
if: steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
env:
E2E_PK: ${{ steps.keys.outputs.pk }}
run: |
echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env
- name: Set up JDK 17
if: matrix.platform == 'android'
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: 17
- name: Set up Gradle cache
if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true'
uses: gradle/actions/setup-gradle@4733eaac7c1b0da527e4206b7671e0061de1ce37 # v6.3.0
with:
# This workflow never runs on main, so the default (write from main
# only) would never seed the cache.
cache-read-only: false
add-job-summary: on-failure
- name: Prebuild Android fixture
if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
run: pnpm expo prebuild --clean --platform android
- name: Build Android fixture
if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
run: pnpm build:android
# The module's Robolectric tests need an Expo host project for expo-modules-core, so they run in the fixture.
# The packed module leaves out its tests, so they are copied in from the repo.
- name: Test expo-biometrics Android module
if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}/android
run: |
cp -R "$GITHUB_WORKSPACE/packages/expo-biometrics/android/src/test" "$(readlink -f ../node_modules/@clerk/expo-biometrics)/android/src/"
./gradlew :clerk-expo-biometrics:testDebugUnitTest
- name: Prebuild iOS fixture
if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
run: pnpm expo prebuild --clean --platform ios
- name: Build iOS fixture
if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true'
working-directory: ${{ env.FIXTURE_DIR }}
run: pnpm build:ios
- name: Save native build
if: steps.native-build-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ steps.native-build-key.outputs.artifact }}
key: ${{ steps.native-build-cache.outputs.cache-primary-key }}
- name: Boot iOS simulators
if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != ''
run: ${{ env.E2E_DIR }}/boot-ios-simulators.sh boot "$MAESTRO_SHARDS"
- name: Cache maestro CLI
if: steps.keys.outputs.pk != ''
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.maestro
key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }}
- name: Install maestro CLI
if: steps.keys.outputs.pk != ''
run: |
if [ -x "$HOME/.maestro/bin/maestro" ]; then
echo "Using cached Maestro"
else
archive="$RUNNER_TEMP/maestro.zip"
curl -fLs --retry 3 --retry-delay 5 -o "$archive" \
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-$MAESTRO_VERSION/maestro.zip"
unzip -q "$archive" -d "$RUNNER_TEMP/maestro-dist"
mv "$RUNNER_TEMP/maestro-dist/maestro" "$HOME/.maestro"
fi
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
test "$("$HOME/.maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
# Provisioned as late as possible so live credentials exist for the
# shortest window.
- name: Provision test user via BAPI
id: user
if: steps.keys.outputs.pk != ''
continue-on-error: true
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
run: |
email="ci-${GITHUB_RUN_ID}-${RANDOM}+clerk_test@clerkcookie.com"
username="ci_${GITHUB_RUN_ID}_${RANDOM}"
password="ClerkCI!$(openssl rand -hex 8)Aa1"
http_code=$(curl -sS -o /tmp/bapi_response.json -w "%{http_code}" -X POST "$BAPI_URL/v1/users" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d "{\"email_address\":[\"$email\"],\"username\":\"$username\",\"password\":\"$password\"}")
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "::error::BAPI user creation failed (HTTP $http_code)"
cat /tmp/bapi_response.json
exit 1
fi
user_id=$(jq -er '.id' /tmp/bapi_response.json)
echo "::add-mask::$password"
{
echo "email=$email"
echo "password=$password"
echo "user_id=$user_id"
} >> "$GITHUB_OUTPUT"
- name: Run iOS e2e (Maestro)
id: run_e2e_ios
if: matrix.platform == 'ios' && steps.user.outputs.user_id != ''
working-directory: ${{ env.FIXTURE_DIR }}
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
run: |
"$GITHUB_WORKSPACE/$E2E_DIR/boot-ios-simulators.sh" wait
mkdir -p "$RUNNER_TEMP/maestro-debug"
log_pids=()
trap 'kill "${log_pids[@]}" 2>/dev/null || true; wait "${log_pids[@]}" 2>/dev/null || true' EXIT
for udid in ${MAESTRO_UDID//,/ }; do
xcrun simctl install "$udid" ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app
xcrun simctl spawn "$udid" log stream --style compact \
--predicate 'processImagePath CONTAINS "ClerkExpoNativeBuildFixture"' \
> "$RUNNER_TEMP/maestro-debug/$udid-console.log" 2>&1 &
log_pids+=($!)
done
PLATFORM=ios MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" \
"$GITHUB_WORKSPACE/$E2E_DIR/run-flows.sh"
- name: Enable KVM
if: matrix.platform == 'android' && steps.user.outputs.user_id != ''
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: AVD cache
id: avd-cache
if: matrix.platform == 'android' && steps.user.outputs.user_id != ''
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-34-google_apis-x86_64-v1
- name: Create AVD snapshot
if: matrix.platform == 'android' && steps.user.outputs.user_id != '' && steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run Android e2e (Maestro)
id: run_e2e_android
if: matrix.platform == 'android' && steps.user.outputs.user_id != ''
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
# read-only: a writable instance holds a lock the shard emulators wait on forever
emulator-options: -read-only -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
# The action runs each script line in a separate sh -c; the folded
# scalar (>-) plus && keeps everything in one shell invocation.
script: >-
cd integration/tests/expo-native &&
MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-android-flows.sh
"$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk"
# Runs before the artifact upload so the credentials are already dead
# by the time the debug files become downloadable.
- name: Cleanup test user
if: always() && steps.user.outputs.user_id != ''
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
USER_ID: ${{ steps.user.outputs.user_id }}
run: |
curl -fsS -X DELETE "$BAPI_URL/v1/users/$USER_ID" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true
# Test reports record flow env (and typed input) in plaintext;
# add-mask only covers step logs, not artifact contents. Must run whenever
# the upload does, or a passing run uploads the password.
- name: Scrub test credentials from Maestro debug output
id: scrub
if: always() && steps.user.outputs.user_id != ''
env:
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
run: |
[ -d "$RUNNER_TEMP/maestro-debug" ] || exit 0
[ -n "$CLERK_TEST_PASSWORD" ] || exit 0
# shellcheck disable=SC2016
find "$RUNNER_TEMP/maestro-debug" -type f \
\( -name '*.html' -o -name '*.json' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' \) \
-exec perl -pi -e 's/\Q$ENV{CLERK_TEST_PASSWORD}\E/[REDACTED]/g' {} +
- name: Upload Maestro artifacts
if: always() && steps.scrub.outcome == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: maestro-${{ matrix.platform }}
path: ${{ runner.temp }}/maestro-debug
if-no-files-found: warn
retention-days: 7
- name: Report e2e outcome
if: always() && steps.user.outputs.user_id != ''
env:
IOS_OUTCOME: ${{ steps.run_e2e_ios.outcome }}
ANDROID_OUTCOME: ${{ steps.run_e2e_android.outcome }}
run: |
outcome="$IOS_OUTCOME"
[ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME"
echo "## Maestro e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY"
if [ "$outcome" = "failure" ]; then
echo "::error::Maestro e2e failed. See the maestro-${{ matrix.platform }} artifact."
fi