native-sim 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,654 @@
1
+ # native-sim-template-version: 18
2
+ # Generated by `native-sim init`. Boots an iOS Simulator on a GitHub-hosted macOS
3
+ # runner, builds this Expo app into it, and streams it back over a tunnel using
4
+ # @expo/serve-sim. The tunnel URL is published as a commit status, which is the
5
+ # only GitHub surface readable *while* the job is still running.
6
+ name: native-sim
7
+ run-name: native-sim ${{ inputs.session }} · ${{ inputs.mode }} · ${{ inputs.minutes }}m
8
+
9
+ on:
10
+ workflow_dispatch:
11
+ inputs:
12
+ session:
13
+ description: Session id (used to correlate the run with the local CLI)
14
+ required: true
15
+ gate_token:
16
+ description: Shared secret required to view the stream
17
+ required: true
18
+ minutes:
19
+ description: How long to hold the stream open
20
+ default: "30"
21
+ device:
22
+ description: Simulator device name
23
+ default: iPhone 17 Pro
24
+ mode:
25
+ description: build (compile from source), app (install a prebuilt .app), or go (Expo Go)
26
+ default: build
27
+ app_url:
28
+ description: URL of a prebuilt simulator .app archive (mode:app)
29
+ default: ""
30
+ app_release_asset:
31
+ description: >-
32
+ Asset name on this repo's `native-sim-build` release to install
33
+ (mode:app). Fetched with the job's own GITHUB_TOKEN, so a private repo
34
+ needs no extra credentials and nothing is publicly downloadable.
35
+ default: ""
36
+ transport:
37
+ description: >-
38
+ http (default) or webrtc. webrtc needs a TURN relay, which is a paid,
39
+ per-GB service, and measured no better than http here — opt in only.
40
+ default: http
41
+ codec:
42
+ description: HTTP stream codec. h264 serves /stream.avcc, which emits nothing on
43
+ GitHub runners; mjpeg is what actually streams.
44
+ default: mjpeg
45
+ max_dimension:
46
+ description: Cap the captured width/height in pixels; 0 keeps native resolution.
47
+ 640 was measurably snappier but visibly soft on a retina display, where the
48
+ preview is drawn at ~2x CSS pixels. 900 keeps it sharp.
49
+ default: "900"
50
+ video_fps:
51
+ description: MJPEG frame rate. serve-sim defaults to 60, which over a tunnel
52
+ stutters more than a reliably delivered 30 and doubles encode CPU.
53
+ default: "30"
54
+ video_quality:
55
+ description: MJPEG quality 0.05-1. Lower means fewer bytes per frame, but it shows
56
+ as compression artefacts rather than softness — reduce fps or dimension first.
57
+ default: "0.7"
58
+ export_app:
59
+ description: Upload the built .app as an artifact so it can be downloaded
60
+ default: "false"
61
+ agent_device:
62
+ description: >-
63
+ Also run an agent-device proxy beside the simulator, reachable at
64
+ <url>/agent-device, so a coding agent on your machine can drive the app.
65
+ default: "false"
66
+ agent_device_version:
67
+ description: agent-device version to install. Pin it — the client and the
68
+ proxied daemon should match.
69
+ default: 0.20.1
70
+ scheme:
71
+ description: Xcode scheme (defaults to the generated workspace name)
72
+ default: ""
73
+ cache:
74
+ description: Reuse a fingerprint-matched .app instead of rebuilding natively
75
+ default: "true"
76
+ runner:
77
+ description: Runner label (must be arm64 — serve-sim ships arm64-only binaries)
78
+ default: macos-26
79
+
80
+ permissions:
81
+ contents: read
82
+ statuses: write
83
+
84
+ concurrency:
85
+ group: native-sim-${{ inputs.session }}
86
+ cancel-in-progress: true
87
+
88
+ jobs:
89
+ stream:
90
+ runs-on: ${{ inputs.runner }}
91
+ # GitHub caps hosted jobs at 6h; the hold step ends the session earlier.
92
+ timeout-minutes: 360
93
+ env:
94
+ GH_TOKEN: ${{ github.token }}
95
+ STATUS_CONTEXT: native-sim/${{ inputs.session }}
96
+ GATE_PORT: "3199"
97
+ PREVIEW_PORT: "3200"
98
+ AGENT_PORT: "4310"
99
+
100
+ steps:
101
+ - uses: actions/checkout@v4
102
+
103
+ - uses: actions/setup-node@v4
104
+ with:
105
+ node-version: 20
106
+
107
+ - name: Cache npm + CocoaPods
108
+ uses: actions/cache@v4
109
+ with:
110
+ path: |
111
+ ~/.npm
112
+ ~/Library/Caches/CocoaPods
113
+ key: native-sim-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/yarn.lock', '**/pnpm-lock.yaml') }}
114
+ restore-keys: native-sim-${{ runner.os }}-
115
+
116
+ - name: Install JS dependencies
117
+ timeout-minutes: 20
118
+ if: inputs.mode != 'app'
119
+ run: |
120
+ set -euo pipefail
121
+ if [ -f pnpm-lock.yaml ]; then
122
+ corepack enable && pnpm install --frozen-lockfile
123
+ elif [ -f yarn.lock ]; then
124
+ corepack enable
125
+ # --immutable is Yarn 2+; Yarn 1 wants --frozen-lockfile.
126
+ yarn install --immutable || yarn install --frozen-lockfile
127
+ elif [ -f package-lock.json ]; then
128
+ npm ci
129
+ else
130
+ npm install
131
+ fi
132
+
133
+ - name: Fingerprint native build inputs
134
+ id: fp
135
+ if: inputs.mode == 'build' && inputs.cache == 'true'
136
+ timeout-minutes: 5
137
+ run: |
138
+ set -euo pipefail
139
+ # @expo/fingerprint hashes exactly the inputs that affect the *native*
140
+ # build (dependencies, config plugins, native dirs) and deliberately
141
+ # ignores application JS, so a JS-only change still hits the cache.
142
+ HASH=$(npx --yes @expo/fingerprint fingerprint:generate --platform ios \
143
+ | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0,"utf8")).hash)')
144
+ echo "hash=$HASH" >> "$GITHUB_OUTPUT"
145
+ echo "native fingerprint: $HASH"
146
+
147
+ - name: Restore cached app build
148
+ id: appcache
149
+ if: inputs.mode == 'build' && inputs.cache == 'true'
150
+ uses: actions/cache/restore@v4
151
+ with:
152
+ path: build/native-sim-app
153
+ key: native-sim-app-${{ runner.os }}-${{ steps.fp.outputs.hash }}
154
+
155
+ - name: Boot simulator
156
+ timeout-minutes: 10
157
+ id: sim
158
+ run: |
159
+ set -euo pipefail
160
+ DEVICE='${{ inputs.device }}'
161
+
162
+ UDID=$(xcrun simctl list devices available \
163
+ | grep -F "$DEVICE (" \
164
+ | head -1 \
165
+ | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/' || true)
166
+
167
+ if [ -z "$UDID" ]; then
168
+ echo "::warning::'$DEVICE' not available on this image; falling back to the newest available iPhone"
169
+ UDID=$(xcrun simctl list devices available \
170
+ | grep -E '^\s+iPhone' \
171
+ | tail -1 \
172
+ | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/')
173
+ fi
174
+
175
+ [ -n "$UDID" ] || { echo "No iOS simulator available on this runner image"; exit 1; }
176
+
177
+ xcrun simctl boot "$UDID" || true
178
+ xcrun simctl bootstatus "$UDID" -b
179
+ echo "udid=$UDID" >> "$GITHUB_OUTPUT"
180
+ xcrun simctl list devices | grep -F "$UDID"
181
+
182
+ - name: Start serve-sim
183
+ timeout-minutes: 10
184
+ env:
185
+ TURN_KEY_ID: ${{ secrets.NATIVE_SIM_TURN_KEY_ID }}
186
+ TURN_KEY_TOKEN: ${{ secrets.NATIVE_SIM_TURN_KEY_TOKEN }}
187
+ TURN_URL: ${{ secrets.NATIVE_SIM_TURN_URL }}
188
+ TURN_USERNAME: ${{ secrets.NATIVE_SIM_TURN_USERNAME }}
189
+ TURN_CREDENTIAL: ${{ secrets.NATIVE_SIM_TURN_CREDENTIAL }}
190
+ STUN_URL: ${{ secrets.NATIVE_SIM_STUN_URL }}
191
+ run: |
192
+ set -euo pipefail
193
+ # MJPEG re-sends a whole JPEG per frame; h264 encodes only what changed,
194
+ # which is the difference between a choppy and a smooth tunnel stream.
195
+ # max-dimension cuts pixels quadratically, saving encode CPU and bytes.
196
+ ARGS=(
197
+ --detach --quiet
198
+ --port "$PREVIEW_PORT"
199
+ --max-dimension '${{ inputs.max_dimension }}'
200
+ --video-fps '${{ inputs.video_fps }}'
201
+ )
202
+
203
+ if [ '${{ inputs.transport }}' = 'webrtc' ] && [ -n "$TURN_KEY_ID" ]; then
204
+ # Cloudflare Realtime mints short-lived TURN credentials, so nothing
205
+ # long-lived is stored and a leak expires on its own.
206
+ echo "Minting TURN credentials from Cloudflare Realtime"
207
+ RESP=$(curl -fsS --retry 2 \
208
+ "https://rtc.live.cloudflare.com/v1/turn/keys/$TURN_KEY_ID/credentials/generate-ice-servers" \
209
+ -H "Authorization: Bearer $TURN_KEY_TOKEN" \
210
+ -H 'Content-Type: application/json' \
211
+ --data '{"ttl": 7200}')
212
+
213
+ # iceServers is an ARRAY: entries without a username are STUN, the
214
+ # entry carrying username/credential holds the TURN urls.
215
+ parse() {
216
+ printf '%s' "$RESP" | node -e '
217
+ let s = "";
218
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
219
+ const list = JSON.parse(s).iceServers || [];
220
+ const turn = list.find((e) => e.username) || {};
221
+ const stun = list.filter((e) => !e.username).flatMap((e) => [].concat(e.urls || []));
222
+ const out = {
223
+ urls: [].concat(turn.urls || []).join(","),
224
+ username: turn.username || "",
225
+ credential: turn.credential || "",
226
+ stun: stun.join(","),
227
+ };
228
+ process.stdout.write(out[process.argv[1]] || "");
229
+ });
230
+ ' "$1"
231
+ }
232
+
233
+ TURN_URL=$(parse urls)
234
+ TURN_USERNAME=$(parse username)
235
+ TURN_CREDENTIAL=$(parse credential)
236
+ STUN_URL=$(parse stun)
237
+
238
+ # Mask before anything else can echo them.
239
+ echo "::add-mask::$TURN_CREDENTIAL"
240
+ echo "::add-mask::$TURN_USERNAME"
241
+ [ -n "$TURN_URL" ] || { echo "::error::Realtime returned no TURN urls"; exit 1; }
242
+ echo "TURN ready: $(printf '%s' "$TURN_URL" | tr ',' '\n' | wc -l | tr -d ' ') relay url(s), $(printf '%s' "$STUN_URL" | tr ',' '\n' | wc -l | tr -d ' ') stun"
243
+ fi
244
+
245
+ if [ '${{ inputs.transport }}' = 'webrtc' ]; then
246
+ # WebRTC adapts bitrate under congestion instead of dropping frames,
247
+ # but a quick tunnel carries no UDP, so TURN is mandatory - STUN alone
248
+ # cannot traverse it. Credentials come from repo secrets, never from
249
+ # workflow inputs, which are visible to anyone who can read the repo.
250
+ [ -n "$TURN_URL" ] || { echo "::error::transport webrtc needs NATIVE_SIM_TURN_URL"; exit 1; }
251
+ ARGS+=(--transport webrtc --webrtc-codec h264 --turn-url "$TURN_URL")
252
+ [ -n "$TURN_USERNAME" ] && ARGS+=(--turn-username "$TURN_USERNAME")
253
+ [ -n "$TURN_CREDENTIAL" ] && ARGS+=(--turn-credential "$TURN_CREDENTIAL")
254
+ [ -n "$STUN_URL" ] && ARGS+=(--stun-url "$STUN_URL")
255
+ else
256
+ # These target MJPEG explicitly: --video-fps only sets h264Fps, and the
257
+ # h264/AVCC path emits no data on GitHub's macOS runners.
258
+ ARGS+=(
259
+ --codec '${{ inputs.codec }}'
260
+ --mjpeg-fps '${{ inputs.video_fps }}'
261
+ --mjpeg-quality '${{ inputs.video_quality }}'
262
+ )
263
+ fi
264
+
265
+ npx --yes @expo/serve-sim@latest "${ARGS[@]}" | tee serve-sim.json
266
+
267
+ # /readyz blocks until the simulator and the native capture session are up.
268
+ for _ in $(seq 1 90); do
269
+ if curl -fsS "http://127.0.0.1:$PREVIEW_PORT/readyz" >/dev/null 2>&1; then
270
+ echo "serve-sim ready"
271
+ exit 0
272
+ fi
273
+ sleep 2
274
+ done
275
+ echo "serve-sim never became ready"
276
+ curl -sS "http://127.0.0.1:$PREVIEW_PORT/healthz" || true
277
+ exit 1
278
+
279
+ - name: Start auth gate
280
+ timeout-minutes: 5
281
+ env:
282
+ NATIVE_SIM_GATE_TOKEN: ${{ inputs.gate_token }}
283
+ # Empty disables the /agent-device route outright, so a session started
284
+ # without --agent exposes no control surface beyond the stream.
285
+ NATIVE_SIM_AGENT_PORT: ${{ inputs.agent_device == 'true' && '4310' || '' }}
286
+ run: |
287
+ set -euo pipefail
288
+ # serve-sim has no auth of its own, so nothing but this gate stands
289
+ # between a leaked tunnel URL and full control of the simulator.
290
+ # The gate starts before the agent-device proxy does; until that comes
291
+ # up, /agent-device/* simply 502s.
292
+ NATIVE_SIM_TARGET_PORT="$PREVIEW_PORT" NATIVE_SIM_GATE_PORT="$GATE_PORT" \
293
+ nohup node .github/native-sim/gate.cjs > gate.log 2>&1 &
294
+ disown
295
+ for _ in $(seq 1 30); do
296
+ curl -fsS -o /dev/null "http://127.0.0.1:$GATE_PORT/__native-sim/healthz" && exit 0
297
+ sleep 1
298
+ done
299
+ cat gate.log; exit 1
300
+
301
+ - name: Open tunnel and publish URL
302
+ timeout-minutes: 10
303
+ id: tunnel
304
+ env:
305
+ GATE_TOKEN: ${{ inputs.gate_token }}
306
+ run: |
307
+ set -euo pipefail
308
+ curl -fsSL -o cloudflared.tgz \
309
+ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz
310
+ tar -xzf cloudflared.tgz
311
+ chmod +x cloudflared
312
+
313
+ nohup ./cloudflared tunnel --no-autoupdate --url "http://127.0.0.1:$GATE_PORT" \
314
+ --logfile cloudflared.log > /dev/null 2>&1 &
315
+ disown
316
+
317
+ TUNNEL=""
318
+ for _ in $(seq 1 60); do
319
+ TUNNEL=$(grep -Eo 'https://[a-z0-9-]+\.trycloudflare\.com' cloudflared.log 2>/dev/null | head -1 || true)
320
+ [ -n "$TUNNEL" ] && break
321
+ sleep 2
322
+ done
323
+ [ -n "$TUNNEL" ] || { echo "cloudflared never reported a URL"; tail -50 cloudflared.log; exit 1; }
324
+
325
+ URL="$TUNNEL/?k=$GATE_TOKEN"
326
+ echo "url=$URL" >> "$GITHUB_OUTPUT"
327
+
328
+ # Commit statuses are world-readable on a public repo, so publish the
329
+ # bare tunnel URL only. The CLI generated the access key and appends it
330
+ # locally; putting it here would hand anyone a working link.
331
+
332
+ # A commit status is the one channel the local CLI can read live.
333
+ gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
334
+ -f state=success \
335
+ -f "context=$STATUS_CONTEXT" \
336
+ -f "target_url=$TUNNEL" \
337
+ -f "description=simulator live; app still building"
338
+
339
+ echo "::notice title=native-sim::$TUNNEL (append the access key the CLI printed)"
340
+ {
341
+ echo "## 📱 native-sim"
342
+ echo
343
+ echo "Simulator live for ${{ inputs.minutes }} minutes at \`$TUNNEL\`."
344
+ echo
345
+ echo "The access key is deliberately not published here — use the full"
346
+ echo "URL the CLI printed. Anyone holding it can drive the simulator."
347
+ } >> "$GITHUB_STEP_SUMMARY"
348
+
349
+ - name: Build app
350
+ timeout-minutes: 60
351
+ if: inputs.mode == 'build' && steps.appcache.outputs.cache-hit != 'true'
352
+ env:
353
+ UDID: ${{ steps.sim.outputs.udid }}
354
+ run: |
355
+ set -euo pipefail
356
+ # Dependencies are already installed above, so skip prebuild's own install pass.
357
+ npx expo prebuild --platform ios --no-install
358
+ npx pod-install ios
359
+
360
+ WORKSPACE=$(find ios -maxdepth 1 -name '*.xcworkspace' | head -1)
361
+ [ -n "$WORKSPACE" ] || { echo "expo prebuild produced no .xcworkspace"; exit 1; }
362
+
363
+ SCHEME='${{ inputs.scheme }}'
364
+ [ -n "$SCHEME" ] || SCHEME=$(basename "$WORKSPACE" .xcworkspace)
365
+ echo "Building scheme '$SCHEME' from $WORKSPACE"
366
+
367
+ xcodebuild \
368
+ -workspace "$WORKSPACE" \
369
+ -scheme "$SCHEME" \
370
+ -configuration Release \
371
+ -sdk iphonesimulator \
372
+ -destination "id=$UDID" \
373
+ -derivedDataPath build/ios \
374
+ CODE_SIGNING_ALLOWED=NO \
375
+ -quiet \
376
+ build
377
+
378
+ APP=$(find build/ios/Build/Products/Release-iphonesimulator -maxdepth 1 -name '*.app' | head -1)
379
+ [ -n "$APP" ] || { echo "No .app produced"; exit 1; }
380
+
381
+ # Park the bundle where the cache (and the install step) expect it.
382
+ rm -rf build/native-sim-app
383
+ mkdir -p build/native-sim-app
384
+ cp -R "$APP" build/native-sim-app/
385
+
386
+ - name: Save app build to cache
387
+ if: inputs.mode == 'build' && inputs.cache == 'true' && steps.appcache.outputs.cache-hit != 'true'
388
+ uses: actions/cache/save@v4
389
+ with:
390
+ path: build/native-sim-app
391
+ key: native-sim-app-${{ runner.os }}-${{ steps.fp.outputs.hash }}
392
+
393
+ - name: Export app archive
394
+ if: inputs.mode == 'build' && inputs.export_app == 'true'
395
+ run: |
396
+ set -euo pipefail
397
+ APP=$(find build/native-sim-app -maxdepth 1 -name '*.app' | head -1)
398
+ [ -n "$APP" ] || { echo "::error::nothing to export"; exit 1; }
399
+ # Tar it: upload-artifact zips its input, and a zipped .app directory
400
+ # loses the executable bits simctl needs.
401
+ tar -C build/native-sim-app -czf "build/$(basename "$APP").tar.gz" "$(basename "$APP")"
402
+ ls -lh build/*.tar.gz
403
+
404
+ - name: Upload app artifact
405
+ if: inputs.mode == 'build' && inputs.export_app == 'true'
406
+ uses: actions/upload-artifact@v4
407
+ with:
408
+ name: native-sim-app-${{ inputs.session }}
409
+ path: build/*.app.tar.gz
410
+ if-no-files-found: error
411
+ retention-days: 1
412
+
413
+ - name: Install and launch app
414
+ timeout-minutes: 15
415
+ if: inputs.mode == 'build'
416
+ env:
417
+ UDID: ${{ steps.sim.outputs.udid }}
418
+ CACHE_HIT: ${{ steps.appcache.outputs.cache-hit }}
419
+ run: |
420
+ set -euo pipefail
421
+ APP=$(find build/native-sim-app -maxdepth 1 -name '*.app' | head -1)
422
+ [ -n "$APP" ] || { echo "No .app to install"; exit 1; }
423
+
424
+ if [ "$CACHE_HIT" = "true" ]; then
425
+ # The fingerprint ignores app JS, so a reused binary carries a stale
426
+ # bundle. Re-embed current JS rather than rebuilding natively.
427
+ echo "Reused cached native build; refreshing the JS bundle"
428
+ npx expo export:embed \
429
+ --platform ios \
430
+ --dev false \
431
+ --bundle-output "$APP/main.jsbundle" \
432
+ --assets-dest "$APP"
433
+ fi
434
+
435
+ BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP/Info.plist")
436
+ xcrun simctl install "$UDID" "$APP"
437
+ xcrun simctl launch "$UDID" "$BUNDLE_ID"
438
+ echo "Launched $BUNDLE_ID"
439
+
440
+ - name: Install prebuilt app
441
+ timeout-minutes: 15
442
+ if: inputs.mode == 'app'
443
+ env:
444
+ UDID: ${{ steps.sim.outputs.udid }}
445
+ APP_URL: ${{ inputs.app_url }}
446
+ APP_RELEASE_ASSET: ${{ inputs.app_release_asset }}
447
+ APP_RELEASE_TAG: native-sim-build
448
+ run: |
449
+ set -euo pipefail
450
+ [ -n "$APP_URL" ] || [ -n "$APP_RELEASE_ASSET" ] \
451
+ || { echo "::error::mode 'app' requires app_url or app_release_asset"; exit 1; }
452
+
453
+ mkdir -p build/prebuilt && cd build/prebuilt
454
+
455
+ if [ -n "$APP_RELEASE_ASSET" ]; then
456
+ # Hosted on this repo's own release. The job token is already scoped
457
+ # here, so a private repo costs no extra credential and a draft
458
+ # release is not downloadable by the public.
459
+ echo "Downloading $APP_RELEASE_ASSET from release $APP_RELEASE_TAG"
460
+ gh release download "$APP_RELEASE_TAG" \
461
+ --repo "$GITHUB_REPOSITORY" \
462
+ --pattern "$APP_RELEASE_ASSET" \
463
+ --output download.bin \
464
+ --clobber
465
+ else
466
+ echo "Downloading prebuilt app"
467
+ curl -fsSL --retry 3 -o download.bin "$APP_URL"
468
+ fi
469
+ file download.bin
470
+
471
+ # EAS simulator builds ship as .tar.gz; some tooling emits .zip.
472
+ case "$(file -b --mime-type download.bin)" in
473
+ application/gzip|application/x-gzip) tar -xzf download.bin ;;
474
+ application/zip) unzip -q download.bin ;;
475
+ *) echo "::error::unrecognised archive; expected .tar.gz or .zip of a simulator .app"; exit 1 ;;
476
+ esac
477
+
478
+ APP=$(find . -maxdepth 3 -type d -name '*.app' | head -1)
479
+ [ -n "$APP" ] || { echo "::error::no .app found in the archive"; exit 1; }
480
+
481
+ # A device .ipa cannot run on a simulator; catch that early with a clear message.
482
+ if [ ! -f "$APP/Info.plist" ]; then
483
+ echo "::error::$APP has no Info.plist — is this a simulator build?"; exit 1
484
+ fi
485
+ if ! /usr/libexec/PlistBuddy -c 'Print CFBundleSupportedPlatforms:0' "$APP/Info.plist" 2>/dev/null | grep -q Simulator; then
486
+ echo "::error::this build targets a device, not a simulator. Build with the EAS 'simulator' profile (ios.simulator: true)."
487
+ exit 1
488
+ fi
489
+
490
+ BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP/Info.plist")
491
+ xcrun simctl install "$UDID" "$APP"
492
+ xcrun simctl launch "$UDID" "$BUNDLE_ID"
493
+ echo "Launched $BUNDLE_ID from prebuilt archive"
494
+
495
+ - name: Start Expo Go
496
+ timeout-minutes: 15
497
+ if: inputs.mode == 'go'
498
+ run: |
499
+ set -euo pipefail
500
+ # `expo start --ios` targets the already-booted simulator and installs
501
+ # Expo Go itself. Metro stays local to the runner.
502
+ CI=1 nohup npx expo start --ios > expo-start.log 2>&1 &
503
+ disown
504
+ for _ in $(seq 1 60); do
505
+ curl -fsS http://127.0.0.1:8081/status >/dev/null 2>&1 && break
506
+ sleep 2
507
+ done
508
+ tail -20 expo-start.log || true
509
+
510
+ - name: Report app installed
511
+ if: inputs.mode == 'build' || inputs.mode == 'app'
512
+ run: |
513
+ gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
514
+ -f state=success \
515
+ -f "context=$STATUS_CONTEXT" \
516
+ -f "description=app installed and running" || true
517
+
518
+ - name: Install agent-device
519
+ id: ad
520
+ if: inputs.agent_device == 'true'
521
+ timeout-minutes: 10
522
+ run: |
523
+ set -euo pipefail
524
+ npm install -g "agent-device@${{ inputs.agent_device_version }}"
525
+ agent-device --version
526
+ # The runner cache key has to pin the Xcode version too: an XCTest
527
+ # runner built against a different Xcode is not reusable.
528
+ echo "xcode=$(xcodebuild -version | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT"
529
+
530
+ - name: Cache agent-device XCTest runner
531
+ if: inputs.agent_device == 'true'
532
+ uses: actions/cache@v4
533
+ with:
534
+ path: ~/.agent-device/apple-runner/derived
535
+ # Exact key, no restore-keys: agent-device documents that broad
536
+ # fallbacks hand back a runner built for another toolchain, which is
537
+ # worse than building one from scratch.
538
+ key: agent-device-runner-${{ runner.os }}-${{ inputs.agent_device_version }}-${{ steps.ad.outputs.xcode }}
539
+
540
+ - name: Start agent-device proxy
541
+ if: inputs.agent_device == 'true'
542
+ timeout-minutes: 15
543
+ env:
544
+ GATE_TOKEN: ${{ inputs.gate_token }}
545
+ # Both default to 300000ms, so the daemon and the XCTest runner are
546
+ # reaped five minutes after the last command — measured here as a
547
+ # session that was healthy at 13:32:36 and gone by 13:37:44, before
548
+ # the agent ever issued its first command. Health probes do not count
549
+ # as activity. 0 means "run until killed", which is right for native-sim:
550
+ # the hold loop *is* the session and the VM is destroyed with it, so
551
+ # there is nothing to leak by staying up.
552
+ AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0"
553
+ AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: "0"
554
+ # A second, independent timer: the *device lease* taken by `open`
555
+ # expires after five minutes without commands, and every later command
556
+ # then fails UNAUTHORIZED ("Lease does not match session owner"). That
557
+ # default protects a shared Mac from an agent that wandered off; here
558
+ # the device is single-tenant and dies with the VM, so hold the lease
559
+ # for the job cap instead. Max must move too, or it clamps the default.
560
+ AGENT_DEVICE_LEASE_TTL_MS: "21600000"
561
+ AGENT_DEVICE_LEASE_MAX_TTL_MS: "21600000"
562
+ run: |
563
+ set -euo pipefail
564
+ # `proxy` serves the daemon HTTP contract under /agent-device/*, which
565
+ # is the base path `connect proxy` expects behind a shared origin — so
566
+ # it rides the tunnel that is already open instead of needing its own.
567
+ # It reuses the gate token: one secret covers both stream and control.
568
+ nohup agent-device proxy --host 127.0.0.1 --port "$AGENT_PORT" \
569
+ --daemon-auth-token "$GATE_TOKEN" > agent-device-proxy.log 2>&1 &
570
+ disown
571
+
572
+ for _ in $(seq 1 60); do
573
+ curl -fsS -o /dev/null "http://127.0.0.1:$AGENT_PORT/health" && break
574
+ sleep 1
575
+ done
576
+ curl -fsS "http://127.0.0.1:$AGENT_PORT/health" \
577
+ || { echo "::error::agent-device proxy never became healthy"; cat agent-device-proxy.log; exit 1; }
578
+ echo "agent-device proxy listening on 127.0.0.1:$AGENT_PORT"
579
+
580
+ # Apple CI wants the XCTest runner built before the first interaction,
581
+ # or the agent's opening snapshot pays several minutes of setup. Run it
582
+ # against the daemon the proxy already started (`proxy` starts *or
583
+ # reuses* one) so no second daemon holds a competing runner lease.
584
+ # 240000 was too tight: a cold build measured 3m40s on one runner and
585
+ # blew past 4 minutes on the next. On timeout agent-device kills the
586
+ # xcodebuild it started, and that takes the daemon down with it — so a
587
+ # tight timeout does not degrade gracefully, it removes the whole
588
+ # agent surface. Give a cold build real room; a cache hit returns in
589
+ # seconds regardless.
590
+ if agent-device prepare ios-runner --platform ios --timeout 900000; then
591
+ echo "XCTest runner ready"
592
+ else
593
+ echo "::warning::prepare ios-runner failed — the supervisor below will restart the daemon"
594
+ tail -40 agent-device-proxy.log || true
595
+ fi
596
+
597
+ - name: Hold the stream open
598
+ env:
599
+ AGENT_ENABLED: ${{ inputs.agent_device }}
600
+ GATE_TOKEN: ${{ inputs.gate_token }}
601
+ AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0"
602
+ AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: "0"
603
+ AGENT_DEVICE_LEASE_TTL_MS: "21600000"
604
+ AGENT_DEVICE_LEASE_MAX_TTL_MS: "21600000"
605
+ run: |
606
+ set -uo pipefail
607
+ MINUTES=${{ inputs.minutes }}
608
+ [ "$MINUTES" -le 350 ] || MINUTES=350
609
+ END=$(( $(date +%s) + MINUTES * 60 ))
610
+ AGENT_LAST_RESTART=0
611
+
612
+ echo "Streaming until $(date -r "$END")"
613
+ while [ "$(date +%s)" -lt "$END" ]; do
614
+ if ! curl -fsS -o /dev/null "http://127.0.0.1:$PREVIEW_PORT/healthz"; then
615
+ echo "::error::serve-sim stopped responding"
616
+ exit 1
617
+ fi
618
+ # A dead agent proxy is not worth tearing down a working stream for,
619
+ # but it should not fail silently either — the agent would just see
620
+ # its next command hang. Warn once and keep streaming.
621
+ # The proxy answers 200 with {"ok":false} when its daemon is gone, so
622
+ # the body has to be inspected — `curl -f` alone reports that as
623
+ # success and the agent surface dies silently.
624
+ #
625
+ # The daemon has died for two unrelated reasons already (idle reap,
626
+ # and a killed xcodebuild taking it down), so rather than enumerate
627
+ # causes, supervise it: whenever it is not healthy, restart it. The
628
+ # session outlives any single daemon that way. Rate-limited to one
629
+ # attempt a minute so a permanently broken daemon does not spin.
630
+ if [ "$AGENT_ENABLED" = "true" ] \
631
+ && ! curl -fsS -m 10 "http://127.0.0.1:$AGENT_PORT/health" 2>/dev/null \
632
+ | grep -q '"ok":true'; then
633
+ NOW=$(date +%s)
634
+ if [ $(( NOW - AGENT_LAST_RESTART )) -ge 60 ]; then
635
+ echo "::warning::agent-device daemon unhealthy; restarting it"
636
+ AGENT_LAST_RESTART=$NOW
637
+ pkill -f 'agent-device proxy' 2>/dev/null || true
638
+ agent-device daemon stop --clean >/dev/null 2>&1 || true
639
+ nohup agent-device proxy --host 127.0.0.1 --port "$AGENT_PORT" \
640
+ --daemon-auth-token "$GATE_TOKEN" >> agent-device-proxy.log 2>&1 &
641
+ disown
642
+ fi
643
+ fi
644
+ sleep 15
645
+ done
646
+ echo "Session window elapsed"
647
+
648
+ - name: Mark session ended
649
+ if: always()
650
+ run: |
651
+ gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
652
+ -f state=failure \
653
+ -f "context=$STATUS_CONTEXT" \
654
+ -f "description=session ended" || true