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.
- package/README.md +395 -0
- package/bin/native-sim.js +8 -0
- package/package.json +31 -0
- package/src/cli.js +151 -0
- package/src/commands/doctor.js +54 -0
- package/src/commands/down.js +48 -0
- package/src/commands/init.js +64 -0
- package/src/commands/r2.js +62 -0
- package/src/commands/status.js +30 -0
- package/src/commands/turn.js +83 -0
- package/src/commands/up.js +288 -0
- package/src/commands/upload.js +60 -0
- package/src/lib/gh.js +170 -0
- package/src/lib/ghrelease.js +50 -0
- package/src/lib/git.js +68 -0
- package/src/lib/proc.js +37 -0
- package/src/lib/project.js +35 -0
- package/src/lib/r2.js +94 -0
- package/src/lib/session.js +24 -0
- package/src/lib/ui.js +44 -0
- package/templates/gate.cjs +184 -0
- package/templates/native-sim.yml +654 -0
|
@@ -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
|