Mobile e2e (@clerk/expo) #106
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
| # 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: "" | |
| clerk_ios_ref: | |
| description: "Optional: pin SPM clerk-ios to this ref (SHA or branch). Empty = use the version pinned in app.plugin.js." | |
| required: false | |
| default: "" | |
| clerk_android_ref: | |
| description: "Optional: pin clerk-android Maven coordinates to this version (e.g. '1.0.17-SNAPSHOT'). When clerk_android_snapshot_suffix is set, this is treated as a git ref (SHA/branch/tag) of clerk-android instead, and the snapshot is published to mavenLocal at runtime. Empty = use the version pinned in android/build.gradle." | |
| required: false | |
| default: "" | |
| clerk_android_snapshot_suffix: | |
| description: "Optional: when set together with clerk_android_ref, the workflow checks out clerk-android at that ref, bumps CLERK_*_VERSION by appending this suffix (e.g. '-expo-compat-123'), publishes to mavenLocal, and pins @clerk/expo to the resulting version. Used by clerk-android's expo-compat release gate." | |
| required: false | |
| default: "" | |
| run_e2e: | |
| description: "Run the e2e build + Maestro jobs. Set false to skip e2e entirely (the fast JS unit tests still run separately in ci.yml). Default true while we validate the suite; flip to false for unit-only once the suite is trusted." | |
| required: false | |
| type: boolean | |
| default: true | |
| workflow_call: | |
| inputs: | |
| quickstart_ref: | |
| type: string | |
| required: false | |
| default: "main" | |
| exclude_tags: | |
| type: string | |
| required: false | |
| default: "manual,skip" | |
| flows_filter: | |
| type: string | |
| required: false | |
| default: "" | |
| clerk_ios_ref: | |
| type: string | |
| required: false | |
| default: "" | |
| clerk_android_ref: | |
| type: string | |
| required: false | |
| default: "" | |
| clerk_android_snapshot_suffix: | |
| type: string | |
| required: false | |
| default: "" | |
| run_e2e: | |
| type: boolean | |
| required: false | |
| default: true | |
| 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: | |
| # Scope by platform so iOS and Android compat-gate dispatches can run in | |
| # parallel without cancelling each other. A rapid re-dispatch with the | |
| # same platform scope still cancels the in-flight one (intended). | |
| group: >- | |
| mobile-e2e-${{ github.ref }}-${{ | |
| (inputs.clerk_ios_ref != '' && inputs.clerk_android_ref == '' && 'ios') || | |
| (inputs.clerk_android_ref != '' && inputs.clerk_ios_ref == '' && 'android') || | |
| 'full' | |
| }} | |
| cancel-in-progress: true | |
| jobs: | |
| android: | |
| name: Android | |
| # Skip Android when the dispatch is scoped to iOS only — used by | |
| # clerk-ios's expo-compat release gate (passes clerk_ios_ref without | |
| # clerk_android_ref). Manual dispatches without ref inputs still run | |
| # both jobs. | |
| if: inputs.run_e2e && (inputs.clerk_ios_ref == '' || inputs.clerk_android_ref != '') | |
| 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: Pin clerk-ios SPM ref (compat-gate mode) | |
| # When the caller (typically clerk-ios release-sdk.yml's expo-compat job) | |
| # passes a specific clerk-ios ref, patch packages/expo/app.plugin.js to | |
| # SHA-pin SPM (`kind: revision`) instead of using the default exact-version | |
| # pin. The binary cache hash recomputes below, so the cache key naturally | |
| # varies by ref and doesn't collide with normal PR / dispatch runs. | |
| if: inputs.clerk_ios_ref != '' | |
| env: | |
| IOS_REF: ${{ inputs.clerk_ios_ref }} | |
| run: | | |
| set -euo pipefail | |
| file="packages/expo/app.plugin.js" | |
| tmp="$(mktemp)" | |
| sed -e "s|const CLERK_IOS_VERSION = '[^']*'|const CLERK_IOS_VERSION = '${IOS_REF}'|" \ | |
| -e "s|kind: 'exactVersion'|kind: 'revision'|g" \ | |
| -e "s|version: CLERK_IOS_VERSION|revision: CLERK_IOS_VERSION|g" \ | |
| "$file" > "$tmp" && mv "$tmp" "$file" | |
| echo "Pinned clerk-ios to ${IOS_REF}:" | |
| grep -nE "CLERK_IOS_VERSION|kind:|revision:" "$file" | head | |
| # --------------------------------------------------------------------- | |
| # clerk-android snapshot mode (compat-gate from clerk-android's release). | |
| # When clerk_android_snapshot_suffix is set, clerk_android_ref is a git | |
| # ref of clerk-android. We check it out, bump CLERK_*_VERSION in its | |
| # gradle.properties by appending the suffix, publish to mavenLocal, | |
| # then pin @clerk/expo to the resulting version + ensure mavenLocal() | |
| # is in the resolution chain. When the suffix is empty, | |
| # clerk_android_ref is a pre-published version string and the | |
| # "version-string mode" step below handles it. | |
| # --------------------------------------------------------------------- | |
| - name: Checkout clerk-android (snapshot mode) | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: clerk/clerk-android | |
| ref: ${{ inputs.clerk_android_ref }} | |
| path: clerk-android | |
| - name: Set up JDK 21 for clerk-android publish (snapshot mode) | |
| # clerk-android requires JDK 21 to build. The quickstart build below | |
| # uses JDK 17 — re-run setup-java to switch back after publish. | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 21 | |
| - name: Compute clerk-android snapshot versions | |
| id: android_versions | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' | |
| working-directory: clerk-android | |
| env: | |
| SUFFIX: ${{ inputs.clerk_android_snapshot_suffix }} | |
| run: | | |
| api="$(awk -F= '/^CLERK_API_VERSION=/{print $2}' gradle.properties | tr -d '[:space:]')${SUFFIX}" | |
| ui="$(awk -F= '/^CLERK_UI_VERSION=/{print $2}' gradle.properties | tr -d '[:space:]')${SUFFIX}" | |
| telemetry="$(awk -F= '/^CLERK_TELEMETRY_VERSION=/{print $2}' gradle.properties | tr -d '[:space:]')${SUFFIX}" | |
| echo "api=$api" >> "$GITHUB_OUTPUT" | |
| echo "ui=$ui" >> "$GITHUB_OUTPUT" | |
| echo "telemetry=$telemetry" >> "$GITHUB_OUTPUT" | |
| echo "Computed snapshot versions: api=$api ui=$ui telemetry=$telemetry" | |
| - name: Publish clerk-android snapshot to mavenLocal | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' | |
| working-directory: clerk-android | |
| env: | |
| API_VERSION: ${{ steps.android_versions.outputs.api }} | |
| UI_VERSION: ${{ steps.android_versions.outputs.ui }} | |
| TELEMETRY_VERSION: ${{ steps.android_versions.outputs.telemetry }} | |
| run: | | |
| sed -i "s/^CLERK_API_VERSION=.*/CLERK_API_VERSION=${API_VERSION}/" gradle.properties | |
| sed -i "s/^CLERK_UI_VERSION=.*/CLERK_UI_VERSION=${UI_VERSION}/" gradle.properties | |
| sed -i "s/^CLERK_TELEMETRY_VERSION=.*/CLERK_TELEMETRY_VERSION=${TELEMETRY_VERSION}/" gradle.properties | |
| # clerk-android's build calls signAllPublications() unconditionally | |
| # on api/ui/telemetry. For snapshot tests we don't have signing keys | |
| # configured and we don't need signed artifacts in mavenLocal, so | |
| # disable every sign* task via an init script (more reliable than | |
| # enumerating -x flags for each module + publication variant). | |
| cat > /tmp/disable-signing.gradle <<'EOG' | |
| allprojects { | |
| tasks.matching { it.name.startsWith("sign") }.configureEach { | |
| enabled = false | |
| } | |
| } | |
| EOG | |
| chmod +x gradlew | |
| # Bump the Gradle wrapper download + dependency HTTP timeouts. | |
| # The default socketTimeout is 10s and services.gradle.org has | |
| # transient slow-responses (saw a 10s-timeout failure that aborted | |
| # the entire compat-gate at the wrapper-fetch step before a single | |
| # publish task ran). 60s is comfortably long without making real | |
| # network outages look like hangs. Also retry the gradlew invocation | |
| # twice to absorb single-shot blips. | |
| export GRADLE_OPTS="${GRADLE_OPTS:-} -Dorg.gradle.internal.http.connectionTimeout=60000 -Dorg.gradle.internal.http.socketTimeout=60000" | |
| for attempt in 1 2 3; do | |
| if ./gradlew \ | |
| -I /tmp/disable-signing.gradle \ | |
| :source:api:publishToMavenLocal \ | |
| :source:ui:publishToMavenLocal \ | |
| :source:telemetry:publishToMavenLocal; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "::error::Gradle publish failed after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "Gradle publish attempt $attempt failed; retrying in 10s..." | |
| sleep 10 | |
| done | |
| - name: Pin @clerk/expo to mavenLocal snapshot | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' | |
| env: | |
| API_VERSION: ${{ steps.android_versions.outputs.api }} | |
| UI_VERSION: ${{ steps.android_versions.outputs.ui }} | |
| run: | | |
| file="packages/expo/android/build.gradle" | |
| sed -i "s|clerkAndroidApiVersion = \"[^\"]*\"|clerkAndroidApiVersion = \"${API_VERSION}\"|" "$file" | |
| sed -i "s|clerkAndroidUiVersion = \"[^\"]*\"|clerkAndroidUiVersion = \"${UI_VERSION}\"|" "$file" | |
| if ! grep -q "mavenLocal()" "$file"; then | |
| sed -i '/^repositories\s*{/a \ mavenLocal()' "$file" || true | |
| fi | |
| echo "Pinned @clerk/expo (snapshot mode):" | |
| grep -nE "clerkAndroid(Api|Ui)Version|mavenLocal" "$file" | head | |
| - name: Pin clerk-android Maven version (version-string mode) | |
| # When the caller passes a pre-published clerk-android version | |
| # (e.g. "1.0.17-SNAPSHOT" already on Maven Central / Sonatype staging), | |
| # rewrite the version constants in packages/expo/android/build.gradle. | |
| # The snapshot-from-SHA path is handled separately above (Android job). | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix == '' | |
| env: | |
| ANDROID_REF: ${{ inputs.clerk_android_ref }} | |
| run: | | |
| set -euo pipefail | |
| file="packages/expo/android/build.gradle" | |
| tmp="$(mktemp)" | |
| sed -e "s|clerkAndroidApiVersion = \"[^\"]*\"|clerkAndroidApiVersion = \"${ANDROID_REF}\"|" \ | |
| -e "s|clerkAndroidUiVersion = \"[^\"]*\"|clerkAndroidUiVersion = \"${ANDROID_REF}\"|" \ | |
| "$file" > "$tmp" && mv "$tmp" "$file" | |
| echo "Pinned clerk-android to ${ANDROID_REF}:" | |
| grep -nE "clerkAndroid(Api|Ui)Version" "$file" | head | |
| - 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). | |
| # | |
| # Also fold the compat-gate ref inputs into the hash. The "Pin clerk-*" | |
| # steps above mutate the working tree (app.plugin.js, build.gradle), | |
| # but `git ls-tree -r HEAD` reads committed blobs and would miss those | |
| # patches — a stale cache hit could then install the OLD SDK refs while | |
| # claiming to test the new ones. Including the input strings here makes | |
| # the cache key honest about what's actually being built. | |
| id: bin-hash | |
| env: | |
| CLERK_IOS_REF: ${{ inputs.clerk_ios_ref }} | |
| CLERK_ANDROID_REF: ${{ inputs.clerk_android_ref }} | |
| CLERK_ANDROID_SNAPSHOT_SUFFIX: ${{ inputs.clerk_android_snapshot_suffix }} | |
| 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\nios=%s\nandroid=%s\nsnapshot=%s\n' \ | |
| "$expo_tree" "$qs_tree" \ | |
| "$CLERK_IOS_REF" "$CLERK_ANDROID_REF" "$CLERK_ANDROID_SNAPSHOT_SUFFIX" \ | |
| | sha256sum | cut -c1-16) | |
| echo "hash=$hash" >> "$GITHUB_OUTPUT" | |
| echo "Binary source hash: $hash (ios=$CLERK_IOS_REF android=$CLERK_ANDROID_REF snapshot=$CLERK_ANDROID_SNAPSHOT_SUFFIX)" | |
| - 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 | |
| # Intentionally no `cache: pnpm` — pnpm/action-setup@v4 already | |
| # caches the pnpm store. setup-node's post-job cache save races | |
| # with the snapshot-mode working-directory changes and fails with | |
| # "Path Validation Error", which bubbles up as a job failure | |
| # even when every test step succeeded. | |
| - 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 | |
| env: | |
| SNAPSHOT_MODE: ${{ inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix != '' && 'true' || 'false' }} | |
| run: | | |
| npx expo prebuild --clean --platform android | |
| # In snapshot mode (compat-gate), inject mavenLocal() into the | |
| # regenerated android/build.gradle so the consumer build can resolve | |
| # the clerk-android snapshot we just publishToMavenLocal'd. Only | |
| # touches project repositories — leaves pluginManagement alone so | |
| # the Gradle Plugin Portal stays the default for resolving the | |
| # kotlin/android plugins. | |
| if [ "$SNAPSHOT_MODE" = "true" ]; then | |
| file="android/build.gradle" | |
| if ! grep -q "mavenLocal()" "$file"; then | |
| awk ' | |
| /^allprojects\s*\{/ { in_all = 1 } | |
| in_all && /repositories\s*\{/ && !done { | |
| print; print " mavenLocal()"; done = 1; next | |
| } | |
| in_all && /^\}/ { in_all = 0 } | |
| { print } | |
| ' "$file" > "$file.tmp" && mv "$file.tmp" "$file" | |
| echo "Injected mavenLocal() into $file:" | |
| grep -nE "mavenLocal|allprojects|repositories" "$file" | head -20 | |
| fi | |
| fi | |
| 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. | |
| # Maestro's `--exclude-tags` only filters when given a directory; | |
| # with explicit file paths it runs every file regardless of tag. | |
| # We want per-flow invocation (one crash/hang can't poison the | |
| # rest) AND tag filtering, so pre-filter the file list in shell: | |
| # for each candidate flow, grep its YAML for a top-level | |
| # `- <excluded-tag>` line and drop it from the list. | |
| # All lines at the same indent so YAML's folded scalar (>-) joins | |
| # them with single spaces. Any extra indentation would preserve | |
| # newlines and break the shell pipe. The while-do-done stays on | |
| # one logical line for the same reason. | |
| script: >- | |
| adb install -r /tmp/cached-app-release.apk && | |
| cd integration/mobile && | |
| excluded="$EXCLUDE_TAGS,iosOnly,flakyAndroid" && | |
| pattern="$(echo "$excluded" | sed 's/,/|/g')" && | |
| ( maestro test --flatten-debug-output flows/common/_warmup.yaml || true ) && | |
| ( adb shell am force-stop com.clerk.clerkexpoquickstart 2>/dev/null || true ) && | |
| find flows -type f -name '*.yaml' ! -path '*/common/*' ! -path '*/native-side/*' ${FLOWS_FILTER:+-path "*$FLOWS_FILTER*"} | |
| | sort | |
| | while read f; do grep -qE "^[[:space:]]*-[[:space:]]*(${pattern})[[:space:]]*$" "$f" || printf '%s\n' "$f"; done | |
| | xargs -n 1 -I FLOW bash -c 'flow="$1"; for a in 1 2; do if maestro test --env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" --env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" --flatten-debug-output "$flow"; then exit 0; fi; if [ "$a" -eq 2 ]; then echo "::error::Flow $flow failed after 2 attempts"; exit 1; fi; echo "::warning::Flow $flow failed attempt $a, retrying after 10s..."; adb shell am force-stop com.clerk.clerkexpoquickstart >/dev/null 2>&1 || true; sleep 10; done' _ FLOW | |
| # Theme verification is a Node post-step (Maestro can't decode PNGs in its | |
| # JS sandbox). The theming flows wrote theme-light.png / theme-dark.png to | |
| # integration/mobile; this asserts each contains a large region of the | |
| # expected themed color. Runs on success OR failure so a flaked flow | |
| # doesn't hide a theming regression; skips cleanly if no theme shots exist. | |
| - name: Verify Android theming | |
| if: success() || failure() | |
| run: bash integration/mobile/scripts/verify-themes.sh | |
| - name: Upload Maestro artifacts on failure or cancel | |
| if: failure() || cancelled() | |
| 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 | |
| # Skip iOS when the dispatch is scoped to Android only — used by | |
| # clerk-android's expo-compat release gate, which passes clerk_android_ref | |
| # (and clerk_android_snapshot_suffix) but not clerk_ios_ref. Manual | |
| # dispatches without ref inputs still run both jobs. | |
| if: inputs.run_e2e && (inputs.clerk_android_ref == '' || inputs.clerk_ios_ref != '') | |
| # GitHub-hosted runner. Blacksmith macOS-15 didn't pick up the job in | |
| # our trial (capacity / label availability) — revisit when Blacksmith | |
| # exposes a label we know is provisioned on this account. | |
| runs-on: macos-15 | |
| # 90 minutes accounts for: ~10 min monorepo install, ~12 min iOS build + | |
| # SPM checkout on a cold derived-data cache, ~3 min cold-sim warmup, | |
| # then ~5-8 min per top-level flow on iOS sim (vs ~45s on Android — iOS | |
| # ASWebAuthenticationSession + simctl install are inherently heavier). | |
| # The previous 60 was tight enough that the suite was killed mid-run on | |
| # cache-miss days even with every flow passing. Tighten this back down | |
| # once we either (a) parallelize flows across shards or (b) move iOS to | |
| # a runner with persistent DerivedData caching. | |
| timeout-minutes: 90 | |
| 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: Pin clerk-ios SPM ref (compat-gate mode) | |
| # When the caller (typically clerk-ios release-sdk.yml's expo-compat job) | |
| # passes a specific clerk-ios ref, patch packages/expo/app.plugin.js to | |
| # SHA-pin SPM (`kind: revision`) instead of using the default exact-version | |
| # pin. The binary cache hash recomputes below, so the cache key naturally | |
| # varies by ref and doesn't collide with normal PR / dispatch runs. | |
| if: inputs.clerk_ios_ref != '' | |
| env: | |
| IOS_REF: ${{ inputs.clerk_ios_ref }} | |
| run: | | |
| set -euo pipefail | |
| file="packages/expo/app.plugin.js" | |
| tmp="$(mktemp)" | |
| sed -e "s|const CLERK_IOS_VERSION = '[^']*'|const CLERK_IOS_VERSION = '${IOS_REF}'|" \ | |
| -e "s|kind: 'exactVersion'|kind: 'revision'|g" \ | |
| -e "s|version: CLERK_IOS_VERSION|revision: CLERK_IOS_VERSION|g" \ | |
| "$file" > "$tmp" && mv "$tmp" "$file" | |
| echo "Pinned clerk-ios to ${IOS_REF}:" | |
| grep -nE "CLERK_IOS_VERSION|kind:|revision:" "$file" | head | |
| - name: Pin clerk-android Maven version (version-string mode) | |
| # When the caller passes a pre-published clerk-android version | |
| # (e.g. "1.0.17-SNAPSHOT" already on Maven Central / Sonatype staging), | |
| # rewrite the version constants in packages/expo/android/build.gradle. | |
| # The snapshot-from-SHA path is handled separately above (Android job). | |
| if: inputs.clerk_android_ref != '' && inputs.clerk_android_snapshot_suffix == '' | |
| env: | |
| ANDROID_REF: ${{ inputs.clerk_android_ref }} | |
| run: | | |
| set -euo pipefail | |
| file="packages/expo/android/build.gradle" | |
| tmp="$(mktemp)" | |
| sed -e "s|clerkAndroidApiVersion = \"[^\"]*\"|clerkAndroidApiVersion = \"${ANDROID_REF}\"|" \ | |
| -e "s|clerkAndroidUiVersion = \"[^\"]*\"|clerkAndroidUiVersion = \"${ANDROID_REF}\"|" \ | |
| "$file" > "$tmp" && mv "$tmp" "$file" | |
| echo "Pinned clerk-android to ${ANDROID_REF}:" | |
| grep -nE "clerkAndroid(Api|Ui)Version" "$file" | head | |
| - name: Compute binary source hash | |
| # See the matching step in the Android job for the rationale on folding | |
| # the compat-gate ref inputs into the hash — same logic applies here. | |
| id: bin-hash | |
| env: | |
| CLERK_IOS_REF: ${{ inputs.clerk_ios_ref }} | |
| CLERK_ANDROID_REF: ${{ inputs.clerk_android_ref }} | |
| CLERK_ANDROID_SNAPSHOT_SUFFIX: ${{ inputs.clerk_android_snapshot_suffix }} | |
| 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\nios=%s\nandroid=%s\nsnapshot=%s\n' \ | |
| "$expo_tree" "$qs_tree" \ | |
| "$CLERK_IOS_REF" "$CLERK_ANDROID_REF" "$CLERK_ANDROID_SNAPSHOT_SUFFIX" \ | |
| | sha256sum | cut -c1-16) | |
| echo "hash=$hash" >> "$GITHUB_OUTPUT" | |
| echo "Binary source hash: $hash (ios=$CLERK_IOS_REF android=$CLERK_ANDROID_REF snapshot=$CLERK_ANDROID_SNAPSHOT_SUFFIX)" | |
| - 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 | |
| # Intentionally no `cache: pnpm` — pnpm/action-setup@v4 already | |
| # caches the pnpm store. setup-node's post-job cache save races | |
| # with the snapshot-mode working-directory changes and fails with | |
| # "Path Validation Error", which bubbles up as a job failure | |
| # even when every test step succeeded. | |
| - 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 | |
| # expo run:ios builds + installs + then tries to deep-link the app | |
| # via the dev-launcher URL (com.<bundle>://expo-development-client/ | |
| # ?url=http://<LAN-IP>:8081). On GitHub-hosted macos-15 runners the | |
| # LAN IP is unreachable from the simulator and `xcrun simctl openurl` | |
| # times out at 60s, exiting expo run:ios with code 1 — even though | |
| # the build itself succeeded and the .app is sitting in DerivedData. | |
| # We don't need the post-install launch (Maestro re-installs and | |
| # opens the app cleanly later), so swallow the openurl failure and | |
| # let the .app-exists check below decide whether to proceed. | |
| set +e | |
| npx expo run:ios --device "$SIM_UDID" --configuration Release --no-bundler | |
| run_ios_rc=$? | |
| set -e | |
| 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 (expo run:ios exit=$run_ios_rc)"; exit 1 | |
| fi | |
| if [ "$run_ios_rc" -ne 0 ]; then | |
| echo "expo run:ios exited with $run_ios_rc but the .app was built ($app); continuing with the cached copy." | |
| 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 | |
| # Kill animations + auto-correct/predictive keyboard on the | |
| # simulator. Both are major slowdowns in CI: animations add | |
| # 200-500ms to every tap; predictive-text suggestions hijack | |
| # text fields and cause Maestro's inputText to land on the | |
| # wrong target. Settings are device-scoped and persist for the | |
| # life of the runner. | |
| xcrun simctl spawn "$SIM_UDID" defaults write com.apple.UIKit UIAnimationDragCoefficient -float 0.01 || true | |
| xcrun simctl spawn "$SIM_UDID" defaults write -g ApplePersistenceIgnoreState -bool YES || true | |
| xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.ContinuousPath -bool NO || true | |
| xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCapitalization -bool NO || true | |
| xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCorrection -bool NO || true | |
| xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.Prediction -bool NO || true | |
| xcrun simctl install "$SIM_UDID" /tmp/cached-clerknativequickstart.app | |
| cd integration/mobile | |
| # Cold-sim warmup: launch the app once and let the JS bundle parse | |
| # and the accessibility tree populate before the per-flow loop | |
| # runs. Without this, whichever flow happens to run first eats the | |
| # cold-start cost and consistently flakes its in-flow wait — even | |
| # though the screen is visually rendered, Maestro's text matcher | |
| # races the a11y tree on freshly-booted runners. | |
| maestro test --flatten-debug-output flows/common/_warmup.yaml || true | |
| # Force-stop the app after warmup. Without this, the first per-flow | |
| # iteration's `launchApp clearState: true` races a still-running app | |
| # process (simctl/adb's clear can't wipe data while the app is | |
| # foregrounded), which intermittently shows up as | |
| # "Launch app ... FAILED" on the very first flow only. | |
| xcrun simctl terminate "$SIM_UDID" com.clerk.clerkexpoquickstart || true | |
| # Maestro's `--exclude-tags` only filters when running a | |
| # DIRECTORY; with explicit file paths Maestro runs every file | |
| # regardless of tag. We want per-flow invocation (so one | |
| # crash/hang can't poison the rest) AND tag filtering, so | |
| # pre-filter the file list. macOS ships bash 3.2 (no mapfile), | |
| # so this uses a portable pipe: skip-messages go to stderr so | |
| # only kept paths flow into xargs. The `sort` step keeps the | |
| # alphabetic order stable across runs so any future first-flow | |
| # quirk is easy to reproduce. | |
| excluded="$EXCLUDE_TAGS,androidOnly" | |
| pattern="$(echo "$excluded" | sed 's/,/|/g')" | |
| find flows -type f -name '*.yaml' ! -path '*/common/*' ! -path '*/native-side/*' \ | |
| ${FLOWS_FILTER:+-path "*$FLOWS_FILTER*"} | sort | \ | |
| while IFS= read -r f; do | |
| if grep -qE "^[[:space:]]*-[[:space:]]*(${pattern})[[:space:]]*\$" "$f"; then | |
| echo "::group::Skip $f (matches excluded tag in ${excluded})" >&2 | |
| grep -E "^[[:space:]]*-[[:space:]]*(${pattern})[[:space:]]*\$" "$f" >&2 | |
| echo "::endgroup::" >&2 | |
| else | |
| printf '%s\n' "$f" | |
| fi | |
| done | \ | |
| xargs -n 1 -I FLOW bash -c ' | |
| flow="$1" | |
| for a in 1 2; do | |
| if maestro test \ | |
| --env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" \ | |
| --env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" \ | |
| --flatten-debug-output \ | |
| "$flow"; then | |
| exit 0 | |
| fi | |
| if [ "$a" -eq 2 ]; then | |
| echo "::error::Flow $flow failed after 2 attempts" | |
| exit 1 | |
| fi | |
| echo "::warning::Flow $flow failed attempt $a, retrying after 10s..." | |
| xcrun simctl terminate "'"$SIM_UDID"'" com.clerk.clerkexpoquickstart >/dev/null 2>&1 || true | |
| sleep 10 | |
| done | |
| ' _ FLOW | |
| # Theme verification is a Node post-step (Maestro can't decode PNGs in its | |
| # JS sandbox). iOS runs the light theme only (dark-mode is androidOnly); | |
| # verify-themes.sh skips any theme shot that wasn't produced. | |
| - name: Verify iOS theming | |
| if: success() || failure() | |
| run: bash integration/mobile/scripts/verify-themes.sh | |
| - name: Upload Maestro artifacts on failure or cancel | |
| if: failure() || cancelled() | |
| 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 |