Skip to content

Mobile e2e (@clerk/expo) #34

Mobile e2e (@clerk/expo)

Mobile e2e (@clerk/expo) #34

Workflow file for this run

# Manual mobile e2e for @clerk/expo native components.
# Clones clerk-expo-quickstart, builds the NativeComponentQuickstart app,
# and runs Maestro flows on iOS simulator and Android emulator.
#
# Secrets:
# INTEGRATION_STAGING_INSTANCE_KEYS — JSON map of named staging test instances
# ({ "<name>": { "pk": "pk_test_...", "sk": "sk_test_..." } }).
# Same secret used by /integration (Playwright) staging jobs. We read the
# entry named EXPO_INSTANCE_NAME (set in env: below).
#
# Test users are provisioned per-run via Clerk Backend API and deleted at
# teardown — same pattern as /integration's createBapiUser.
name: "Mobile e2e (@clerk/expo)"
on:
workflow_dispatch:
inputs:
quickstart_ref:
description: "clerk-expo-quickstart git ref (branch, tag, or SHA)"
required: false
default: "main"
exclude_tags:
description: "Maestro tags to exclude (comma-separated)"
required: false
default: "manual,skip"
flows_filter:
description: "Optional: substring filter for flow paths (e.g. 'sign-in/email-password'). Empty = all flows."
required: false
default: ""
env:
EXPO_INSTANCE_NAME: clerkstage-with-native-components
# Override the quickstart's checked-in .npmrc, which points pnpm/npm/npx at a
# local verdaccio registry (http://localhost:4873) that doesn't exist on CI.
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
concurrency:
group: mobile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
name: Android
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
timeout-minutes: 45
defaults:
run:
working-directory: .
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4
- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart
- name: Compute binary source hash
# Hash everything that affects the produced APK: @clerk/expo source,
# the workflow file (which encodes the quickstart-modification rules),
# and the quickstart source itself. node_modules, android/, and ios/
# are excluded (they're build outputs / regenerated by prebuild).
id: bin-hash
run: |
expo_tree=$(git ls-tree -r HEAD packages/expo .github/workflows/mobile-e2e.yml | grep -v "/dist/")
qs_tree=$(git -C clerk-expo-quickstart ls-tree -r HEAD NativeComponentQuickstart | grep -vE "node_modules|/android/|/ios/")
hash=$(printf '%s\n%s\n' "$expo_tree" "$qs_tree" | sha256sum | cut -c1-16)
echo "hash=$hash" >> "$GITHUB_OUTPUT"
echo "Binary source hash: $hash"
- name: Restore Android APK cache
# On hit, the entire build path below is skipped. Cache key includes
# EXPO_INSTANCE_NAME because the publishable key is baked into the JS
# bundle at build time.
id: apk-cache
uses: actions/cache/restore@v4
with:
path: /tmp/cached-app-release.apk
key: android-apk-${{ steps.bin-hash.outputs.hash }}-${{ env.EXPO_INSTANCE_NAME }}-v1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install monorepo deps
if: steps.apk-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
- name: Build @clerk/expo
if: steps.apk-cache.outputs.cache-hit != 'true'
run: pnpm turbo build --filter=@clerk/expo...
- name: Pack @clerk/expo
if: steps.apk-cache.outputs.cache-hit != 'true'
# `pnpm pack` resolves workspace:^ deps to real versions in the
# packed tarball, which is what we need so the quickstart (outside
# the workspace) can install it.
run: pnpm --filter @clerk/expo pack --pack-destination /tmp/clerk-expo-pkg
- name: Point quickstart at packed @clerk/expo and configure for CI
if: steps.apk-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
# The quickstart's main pins @clerk/expo to a local verdaccio
# snapshot version which doesn't exist on public npm. Swap it for
# the tarball we just packed.
tarball=$(ls /tmp/clerk-expo-pkg/clerk-expo-*.tgz | head -1)
jq --arg t "file:$tarball" '.dependencies["@clerk/expo"] = $t' package.json > package.json.tmp
mv package.json.tmp package.json
# Disable Apple Sign-In in the @clerk/expo plugin. Its default behavior
# injects the com.apple.developer.applesignin entitlement during prebuild,
# which makes Expo CLI's simulatorBuildRequiresCodeSigning() return true
# and demand a development signing identity even for simulator builds —
# CI doesn't have one. Maestro flows can't exercise Apple Sign-In without
# an Apple Developer team configured anyway.
# The plugin may be listed as a bare string "@clerk/expo" OR in array
# form ["@clerk/expo", { ...config }] (the quickstart uses the latter
# with a theme config). Handle both: rewrite the bare form, or merge
# appleSignIn: false into the existing config object.
jq '.expo.plugins |= map(
if . == "@clerk/expo" then ["@clerk/expo", {"appleSignIn": false}]
elif type == "array" and .[0] == "@clerk/expo" then [.[0], ((.[1] // {}) + {"appleSignIn": false})]
else . end
)' app.json > app.json.tmp
mv app.json.tmp app.json
# The quickstart's app.json ships with placeholder bundle ids
# ("com.yourcompany.yourapp") but the Maestro flows in
# integration-mobile/flows reference "com.clerk.clerkexpoquickstart".
# Align them so launchApp/clearAppState target the installed app.
jq '.expo.ios.bundleIdentifier = "com.clerk.clerkexpoquickstart" | .expo.android.package = "com.clerk.clerkexpoquickstart"' app.json > app.json.tmp
mv app.json.tmp app.json
# Strip expo-dev-client. With it installed, even release-variant
# builds boot into the dev launcher and try to reach Metro at a
# LAN IP unreachable from CI's emulator/simulator, leaving every
# Maestro flow stuck on a blank screen.
jq 'del(.dependencies["expo-dev-client"], .devDependencies["expo-dev-client"])' package.json > package.json.tmp
mv package.json.tmp package.json
- name: Cache quickstart node_modules
if: steps.apk-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: clerk-expo-quickstart/NativeComponentQuickstart/node_modules
key: quickstart-nm-${{ runner.os }}-${{ hashFiles('clerk-expo-quickstart/NativeComponentQuickstart/package.json', 'packages/expo/package.json') }}
restore-keys: |
quickstart-nm-${{ runner.os }}-
- name: Install quickstart deps
if: steps.apk-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
# --ignore-workspace because the quickstart dir is nested inside the
# javascript checkout; without this, pnpm walks up and treats the
# outer monorepo as the workspace and skips the quickstart entirely.
run: pnpm install --ignore-workspace --no-frozen-lockfile
- name: Stub missing image assets
if: steps.apk-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
# The quickstart's app.json references splash-icon.png and android
# adaptive-icon variants that aren't actually committed. Fill them in
# from icon.png so prebuild's image-asset mods don't fail with ENOENT.
run: |
cd assets/images
for f in splash-icon.png android-icon-foreground.png android-icon-background.png android-icon-monochrome.png; do
[ -f "$f" ] || cp icon.png "$f"
done
- name: Resolve Clerk instance keys
id: keys
env:
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$EXPO_INSTANCE_NAME"
- name: Write quickstart .env
if: steps.apk-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ steps.keys.outputs.pk }}" > .env
- name: Provision test user via BAPI
id: user
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 https://api.clerkstage.dev/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)"
jq . /tmp/bapi_response.json 2>/dev/null || cat /tmp/bapi_response.json
exit 1
fi
response=$(cat /tmp/bapi_response.json)
user_id=$(echo "$response" | jq -er '.id')
echo "::add-mask::$password"
echo "email=$email" >> "$GITHUB_OUTPUT"
echo "password=$password" >> "$GITHUB_OUTPUT"
echo "user_id=$user_id" >> "$GITHUB_OUTPUT"
- name: Verify BAPI user
# Sanity check: confirm the just-created user is visible to BAPI with
# the expected properties before we hand off to Maestro. If sign-in
# flows start failing again, this step's output is the first place
# to look (and pre-dates the maestro driver coming up).
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
USER_ID: ${{ steps.user.outputs.user_id }}
run: |
curl -fsS -H "Authorization: Bearer $CLERK_SECRET_KEY" "https://api.clerkstage.dev/v1/users/$USER_ID" \
| jq '{id, email_addresses: [.email_addresses[].email_address], password_enabled, banned, locked}'
- name: Set up JDK 17
# Always run, not just on cache miss: Maestro itself requires Java 17+
# to launch (the maestro CLI is a JVM app). When the APK cache hits
# and we skip gradle, we still need Java for the Maestro test step.
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Cache Gradle
if: steps.apk-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('packages/expo/package.json', 'clerk-expo-quickstart/NativeComponentQuickstart/package.json') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Build Android APK
# Build with prebuild + gradle directly so we can do it before booting
# the emulator (gradle assembleRelease doesn't need a device, unlike
# `expo run:android`). Output is copied to a stable cache path.
if: steps.apk-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
npx expo prebuild --clean --platform android
cd android
( ok=0; for i in 1 2 3; do if ./gradlew :app:assembleRelease; then ok=1; break; fi; echo "gradle attempt $i failed; retrying in 15s"; sleep 15; done; [ "$ok" = 1 ] )
cp app/build/outputs/apk/release/app-release.apk /tmp/cached-app-release.apk
ls -lh /tmp/cached-app-release.apk
- name: Save Android APK cache
# Save runs on success (no `always()` - if build failed, don't cache a
# missing or partial APK). Skipped on cache hit since the binary is
# already there.
if: steps.apk-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: /tmp/cached-app-release.apk
key: android-apk-${{ steps.bin-hash.outputs.hash }}-${{ env.EXPO_INSTANCE_NAME }}-v1
- name: Enable KVM
# Required so the x86_64 Android emulator can use hardware accel.
# Without it the emulator falls back to software rendering and is
# multiple times slower to boot and run.
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
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-34-google_apis-x86_64-v1
- name: Create AVD snapshot
# On cache miss, boot the emulator once with snapshot saving so a
# warm-boot snapshot ends up in ~/.android/avd/*. Subsequent runs
# hit the cache and skip this step.
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@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: Install Maestro
# Use pipefail + verify the binary so a curl flake (e.g. TLS reset
# against github.com:443) doesn't leave us with an "installed" but
# missing maestro binary, which downstream xargs invocations only
# surface as a cryptic exit-code-127 after the build.
run: |
set -o pipefail
installed=0
for i in 1 2 3; do
if curl -fLs --retry 3 --retry-delay 5 "https://get.maestro.mobile.dev" | bash; then
if [ -x "$HOME/.maestro/bin/maestro" ]; then installed=1; break; fi
fi
echo "Maestro install attempt $i failed (or binary missing); retrying"
sleep 5
done
[ "$installed" = 1 ] || { echo "::error::Maestro install failed after 3 attempts"; exit 1; }
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
"$HOME/.maestro/bin/maestro" --version
- name: Run Android e2e
uses: reactivecircus/android-emulator-runner@v2
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
EXCLUDE_TAGS: ${{ inputs.exclude_tags }}
FLOWS_FILTER: ${{ inputs.flows_filter }}
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
# reactivecircus/android-emulator-runner runs each line of `script` in a
# separate `sh -c` invocation, so cwd doesn't persist between commands.
# Use a folded scalar (>-) plus `&&` chains so the entire pipeline runs
# in one shell invocation. Maestro doesn't auto-recurse into subdirs,
# so we pass each flow file explicitly via find.
script: >-
adb install -r /tmp/cached-app-release.apk &&
cd integration-mobile &&
find flows -type f -name "*.yaml" ! -path "*/common/*"
${FLOWS_FILTER:+-path "*$FLOWS_FILTER*"} -print0
| xargs -0 maestro test --env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" --env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" --exclude-tags "$EXCLUDE_TAGS"
- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-android
# ~/.maestro/tests holds Maestro's auto-captured failure screenshots
# and commands JSON. integration-mobile/*.png holds the takeScreenshot
# debug captures that flows write, which Maestro saves relative to
# the cwd it was launched from (integration-mobile/).
path: |
~/.maestro/tests
integration-mobile/*.png
- 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 "https://api.clerkstage.dev/v1/users/$USER_ID" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true
ios:
name: iOS
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4
- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart
- name: Compute binary source hash
id: bin-hash
run: |
expo_tree=$(git ls-tree -r HEAD packages/expo .github/workflows/mobile-e2e.yml | grep -v "/dist/")
qs_tree=$(git -C clerk-expo-quickstart ls-tree -r HEAD NativeComponentQuickstart | grep -vE "node_modules|/android/|/ios/")
hash=$(printf '%s\n%s\n' "$expo_tree" "$qs_tree" | sha256sum | cut -c1-16)
echo "hash=$hash" >> "$GITHUB_OUTPUT"
echo "Binary source hash: $hash"
- name: Restore iOS .app cache
id: app-cache
uses: actions/cache/restore@v4
with:
path: /tmp/cached-clerknativequickstart.app
key: ios-app-${{ steps.bin-hash.outputs.hash }}-${{ env.EXPO_INSTANCE_NAME }}-v1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install monorepo deps
if: steps.app-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
- name: Build @clerk/expo
if: steps.app-cache.outputs.cache-hit != 'true'
run: pnpm turbo build --filter=@clerk/expo...
- name: Pack @clerk/expo
if: steps.app-cache.outputs.cache-hit != 'true'
# `pnpm pack` resolves workspace:^ deps to real versions in the
# packed tarball, which is what we need so the quickstart (outside
# the workspace) can install it.
run: pnpm --filter @clerk/expo pack --pack-destination /tmp/clerk-expo-pkg
- name: Point quickstart at packed @clerk/expo and configure for CI
if: steps.app-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
# The quickstart's main pins @clerk/expo to a local verdaccio
# snapshot version which doesn't exist on public npm. Swap it for
# the tarball we just packed.
tarball=$(ls /tmp/clerk-expo-pkg/clerk-expo-*.tgz | head -1)
jq --arg t "file:$tarball" '.dependencies["@clerk/expo"] = $t' package.json > package.json.tmp
mv package.json.tmp package.json
# Disable Apple Sign-In in the @clerk/expo plugin. Its default behavior
# injects the com.apple.developer.applesignin entitlement during prebuild,
# which makes Expo CLI's simulatorBuildRequiresCodeSigning() return true
# and demand a development signing identity even for simulator builds —
# CI doesn't have one. Maestro flows can't exercise Apple Sign-In without
# an Apple Developer team configured anyway.
# The plugin may be listed as a bare string "@clerk/expo" OR in array
# form ["@clerk/expo", { ...config }] (the quickstart uses the latter
# with a theme config). Handle both: rewrite the bare form, or merge
# appleSignIn: false into the existing config object.
jq '.expo.plugins |= map(
if . == "@clerk/expo" then ["@clerk/expo", {"appleSignIn": false}]
elif type == "array" and .[0] == "@clerk/expo" then [.[0], ((.[1] // {}) + {"appleSignIn": false})]
else . end
)' app.json > app.json.tmp
mv app.json.tmp app.json
# The quickstart's app.json ships with placeholder bundle ids
# ("com.yourcompany.yourapp") but the Maestro flows in
# integration-mobile/flows reference "com.clerk.clerkexpoquickstart".
# Align them so launchApp/clearAppState target the installed app.
jq '.expo.ios.bundleIdentifier = "com.clerk.clerkexpoquickstart" | .expo.android.package = "com.clerk.clerkexpoquickstart"' app.json > app.json.tmp
mv app.json.tmp app.json
# Strip expo-dev-client. With it installed, even release-variant
# builds boot into the dev launcher and try to reach Metro at a
# LAN IP unreachable from CI's emulator/simulator, leaving every
# Maestro flow stuck on a blank screen.
jq 'del(.dependencies["expo-dev-client"], .devDependencies["expo-dev-client"])' package.json > package.json.tmp
mv package.json.tmp package.json
- name: Cache quickstart node_modules
if: steps.app-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: clerk-expo-quickstart/NativeComponentQuickstart/node_modules
key: quickstart-nm-${{ runner.os }}-${{ hashFiles('clerk-expo-quickstart/NativeComponentQuickstart/package.json', 'packages/expo/package.json') }}
restore-keys: |
quickstart-nm-${{ runner.os }}-
- name: Install quickstart deps
if: steps.app-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
# --ignore-workspace because the quickstart dir is nested inside the
# javascript checkout; without this, pnpm walks up and treats the
# outer monorepo as the workspace and skips the quickstart entirely.
run: pnpm install --ignore-workspace --no-frozen-lockfile
- name: Stub missing image assets
if: steps.app-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
# The quickstart's app.json references splash-icon.png and android
# adaptive-icon variants that aren't actually committed. Fill them in
# from icon.png so prebuild's image-asset mods don't fail with ENOENT.
run: |
cd assets/images
for f in splash-icon.png android-icon-foreground.png android-icon-background.png android-icon-monochrome.png; do
[ -f "$f" ] || cp icon.png "$f"
done
- name: Resolve Clerk instance keys
id: keys
env:
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$EXPO_INSTANCE_NAME"
- name: Write quickstart .env
if: steps.app-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ steps.keys.outputs.pk }}" > .env
- name: Provision test user via BAPI
id: user
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 https://api.clerkstage.dev/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)"
jq . /tmp/bapi_response.json 2>/dev/null || cat /tmp/bapi_response.json
exit 1
fi
response=$(cat /tmp/bapi_response.json)
user_id=$(echo "$response" | jq -er '.id')
echo "::add-mask::$password"
echo "email=$email" >> "$GITHUB_OUTPUT"
echo "password=$password" >> "$GITHUB_OUTPUT"
echo "user_id=$user_id" >> "$GITHUB_OUTPUT"
- name: Verify BAPI user
# Sanity check: confirm the just-created user is visible to BAPI with
# the expected properties before we hand off to Maestro. If sign-in
# flows start failing again, this step's output is the first place
# to look (and pre-dates the maestro driver coming up).
env:
CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }}
USER_ID: ${{ steps.user.outputs.user_id }}
run: |
curl -fsS -H "Authorization: Bearer $CLERK_SECRET_KEY" "https://api.clerkstage.dev/v1/users/$USER_ID" \
| jq '{id, email_addresses: [.email_addresses[].email_address], password_enabled, banned, locked}'
- name: Cache CocoaPods downloads
if: steps.app-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: cocoapods-${{ runner.os }}-${{ hashFiles('packages/expo/package.json', 'clerk-expo-quickstart/NativeComponentQuickstart/package.json') }}
restore-keys: |
cocoapods-${{ runner.os }}-
- name: Cache Xcode DerivedData
# Includes SPM checkouts and incremental build artifacts. Wider key
# than the old spm-only cache so it actually invalidates when the
# quickstart's deps change, not just when @clerk/expo's do.
if: steps.app-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData
key: deriveddata-${{ runner.os }}-${{ hashFiles('packages/expo/package.json', 'clerk-expo-quickstart/NativeComponentQuickstart/package.json') }}
restore-keys: |
deriveddata-${{ runner.os }}-
- name: Build iOS .app
# Build with expo run:ios (which builds + installs to the booted sim).
# We then extract the .app bundle from DerivedData to a stable cache
# path. The Maestro step below installs from that path regardless of
# whether the build ran or the cache restored.
if: steps.app-cache.outputs.cache-hit != 'true'
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: |
npx expo prebuild --clean --platform ios
SIM_UDID=$(xcrun simctl list devices --json | jq -r '[.devices | to_entries[] | select(.key | startswith("com.apple.CoreSimulator.SimRuntime.iOS")) | .value[] | select(.isAvailable == true)] | first | .udid')
if [ -z "$SIM_UDID" ] || [ "$SIM_UDID" = "null" ]; then
echo "::error::No available iOS simulator found"; xcrun simctl list devices; exit 1
fi
xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b
npx expo run:ios --device "$SIM_UDID" --configuration Release --no-bundler
app=$(find ~/Library/Developer/Xcode/DerivedData -name "clerknativequickstart.app" -path "*/Release-iphonesimulator/*" | head -1)
if [ -z "$app" ]; then echo "::error::No .app found in DerivedData"; exit 1; fi
rm -rf /tmp/cached-clerknativequickstart.app
cp -R "$app" /tmp/cached-clerknativequickstart.app
ls -la /tmp/cached-clerknativequickstart.app | head
- name: Save iOS .app cache
if: steps.app-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: /tmp/cached-clerknativequickstart.app
key: ios-app-${{ steps.bin-hash.outputs.hash }}-${{ env.EXPO_INSTANCE_NAME }}-v1
- name: Install Maestro
# Use pipefail + verify the binary so a curl flake (e.g. TLS reset
# against github.com:443) doesn't leave us with an "installed" but
# missing maestro binary, which downstream xargs invocations only
# surface as a cryptic exit-code-127 after the build.
run: |
set -o pipefail
installed=0
for i in 1 2 3; do
if curl -fLs --retry 3 --retry-delay 5 "https://get.maestro.mobile.dev" | bash; then
if [ -x "$HOME/.maestro/bin/maestro" ]; then installed=1; break; fi
fi
echo "Maestro install attempt $i failed (or binary missing); retrying"
sleep 5
done
[ "$installed" = 1 ] || { echo "::error::Maestro install failed after 3 attempts"; exit 1; }
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
"$HOME/.maestro/bin/maestro" --version
- name: Run iOS e2e
# Boot a simulator, install the cached (or freshly-built) .app, and
# run the Maestro sweep. No xcodebuild here — that all happened in
# "Build iOS .app" or was restored from the actions/cache step.
env:
CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }}
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
EXCLUDE_TAGS: ${{ inputs.exclude_tags }}
FLOWS_FILTER: ${{ inputs.flows_filter }}
run: |
SIM_UDID=$(xcrun simctl list devices --json | jq -r '[.devices | to_entries[] | select(.key | startswith("com.apple.CoreSimulator.SimRuntime.iOS")) | .value[] | select(.isAvailable == true)] | first | .udid')
if [ -z "$SIM_UDID" ] || [ "$SIM_UDID" = "null" ]; then
echo "::error::No available iOS simulator found"
xcrun simctl list devices
exit 1
fi
echo "Using simulator $SIM_UDID"
xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b
xcrun simctl install "$SIM_UDID" /tmp/cached-clerknativequickstart.app
cd integration-mobile
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" \
${FLOWS_FILTER:+-path "*$FLOWS_FILTER*"} -print0 | \
xargs -0 maestro test \
--env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" \
--env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" \
--exclude-tags "$EXCLUDE_TAGS,androidOnly"
- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-ios
path: |
~/.maestro/tests
integration-mobile/*.png
- 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 "https://api.clerkstage.dev/v1/users/$USER_ID" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true