1name: Mobile Dev Client
2run-name: ${{ github.event_name == 'workflow_dispatch' && format('Dev client {0}{1} {2}', inputs.platform, inputs.platform == 'android' && format(' {0}', inputs.abi) || '', inputs.fingerprint) || '' }}
3
4# Builds the simulator and emulator development client for a commit's native
5# fingerprint and shares it through the native-client cache, the one
6# `vp run mobile:install` reads on every machine. Android builds with Gradle,
7# ccache, and pnpm state on a sticky disk; iOS builds on a macOS runner, so no
8# machine needs Xcode or an EAS build for it.
9#
10# - push to main: main's client is cached before anyone asks for it.
11# - workflow_dispatch: `mobile:install` starts this for the checkout's HEAD on a
12# cache miss, when the person running it may dispatch workflows here, and
13# waits for the client in the cache. The run name is how it finds its run.
14# - pull_request (this file or the script): builds without sharing, as a check.
15on:
16 push:
17 branches: [main]
18 paths:
19 - apps/mobile/**
20 - packages/client-runtime/**
21 - packages/contracts/**
22 - packages/shared/**
23 - assets/**
24 - patches/**
25 - scripts/mobile-native-client.ts
26 - package.json
27 - pnpm-lock.yaml
28 - pnpm-workspace.yaml
29 - .github/workflows/mobile-dev-client.yml
30 pull_request:
31 paths:
32 - .github/workflows/mobile-dev-client.yml
33 - scripts/mobile-native-client.ts
34 workflow_dispatch:
35 inputs:
36 ref:
37 description: Commit to build (default main)
38 required: false
39 type: string
40 platform:
41 description: Platform
42 required: true
43 default: android
44 type: choice
45 options: [android, ios]
46 abi:
47 description: Android ABI of the device
48 required: true
49 default: x86_64
50 type: choice
51 options: [x86_64, arm64-v8a]
52 fingerprint:
53 description: Native fingerprint the caller expects; the run fails when the commit's differs
54 required: false
55 type: string
56
57permissions:
58 contents: read
59
60jobs:
61 android:
62 name: Android ${{ matrix.abi }}
63 if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'android')
64 runs-on: blacksmith-32vcpu-ubuntu-2404
65 timeout-minutes: 45
66 strategy:
67 fail-fast: false
68 matrix:
69 # Pool emulators are x86_64; a dispatch builds the ABI it asks for.
70 abi: ${{ github.event_name == 'workflow_dispatch' && fromJSON(format('["{0}"]', inputs.abi)) || fromJSON('["x86_64"]') }}
71 concurrency:
72 # One run per client: a second request for the same fingerprint waits and finds it
73 # in the cache. A shared group would drop every waiting run but the newest.
74 group: mobile-dev-client-android-${{ matrix.abi }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }}
75 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
76 env:
77 NODE_OPTIONS: --max-old-space-size=8192
78 ABI: ${{ matrix.abi }}
79 EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }}
80 # A pull request checks that the build works: it builds even on a cache hit and shares nothing.
81 T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }}
82 # Gradle and Xcode output goes to the job log, not a file on the runner.
83 T3_NATIVE_CLIENT_VERBOSE: "1"
84 steps:
85 - name: Checkout
86 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
87 with:
88 ref: ${{ inputs.ref || github.sha }}
89
90 # A dispatch names any commit GitHub has, including a fork's. Only a commit that
91 # collaborators pushed here builds with the cache login and the shared disk.
92 - name: Check the commit belongs to this repository
93 if: github.event_name == 'workflow_dispatch'
94 env:
95 GH_TOKEN: ${{ github.token }}
96 run: |
97 sha="$(git rev-parse HEAD)"
98 if git ls-remote --heads origin | grep -q "^$sha"; then
99 exit 0
100 fi
101 status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)"
102 if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then
103 exit 0
104 fi
105 echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally."
106 exit 1
107
108 # The build runs dependency scripts; it gets no token. checkout keeps the token in a
109 # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials:
110 # false` fails here: its cleanup runs `git submodule foreach`, which stops at the
111 # vendored gitlinks under .repos (no .gitmodules entry).
112 - name: Drop checkout credentials
113 run: |
114 git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do
115 rm -f "$file"
116 git config --local --unset-all "$key"
117 done
118 git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do
119 git config --local --unset-all "$key"
120 done
121 rm -f "$RUNNER_TEMP"/git-credentials-*.config
122 if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then
123 echo "::error::checkout credentials are still present"
124 exit 1
125 fi
126
127 - name: Prepare build directories
128 run: |
129 cache="$HOME/.cache/t3-android-dev-client"
130 {
131 echo "GRADLE_USER_HOME=$cache/gradle"
132 echo "CCACHE_DIR=$cache/ccache"
133 echo "CCACHE_MAXSIZE=5G"
134 # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime.
135 echo "CCACHE_COMPILERCHECK=%compiler% -v"
136 # The checkout path is the same on every run, so ccache keys stay stable.
137 echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE"
138 # CMake (3.17+) reads these, and AGP passes Gradle's environment through.
139 echo "CMAKE_C_COMPILER_LAUNCHER=ccache"
140 echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache"
141 } >> "$GITHUB_ENV"
142
143 # Pull requests build cold: their code must not write Gradle or ccache state that
144 # the runs holding the cache login restore.
145 - name: Mount build cache
146 if: github.event_name != 'pull_request'
147 uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
148 with:
149 key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }}
150 path: ~/.cache/t3-android-dev-client
151
152 - name: Configure Gradle and ccache
153 run: |
154 mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR"
155 # No daemon: nothing should hold the sticky disk open when it is committed.
156 printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties"
157 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
158 sudo apt-get update -qq && sudo apt-get install -y -qq ccache
159 ccache --zero-stats
160
161 - name: Setup dependencies
162 uses: ./.github/actions/setup-ci-deps
163 with:
164 task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
165 install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts...
166
167 - name: Setup Java
168 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
169 with:
170 distribution: temurin
171 java-version: 17
172
173 # Only this step's login can write to the cache; pull requests never get it.
174 # Only this login can write to the cache; pull requests never get it.
175 - id: login
176 name: Log in to the native client cache
177 if: github.event_name != 'pull_request'
178 env:
179 WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }}
180 run: |
181 if [ -z "$WRITE_TOKEN" ]; then
182 echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built."
183 exit 0
184 fi
185 node --input-type=module <<'NODE'
186 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
187 import { homedir } from "node:os";
188 import { join } from "node:path";
189 const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8"));
190 const directory = join(homedir(), ".config/t3-task-cache");
191 mkdirSync(directory, { recursive: true, mode: 0o700 });
192 writeFileSync(
193 join(directory, "credentials.json"),
194 JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }),
195 { mode: 0o600 },
196 );
197 NODE
198 echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV"
199 echo "ready=true" >> "$GITHUB_OUTPUT"
200
201 - name: Build and share
202 if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true'
203 shell: bash # pipefail: a failed build fails the step through tee
204 run: |
205 node scripts/mobile-native-client.ts ensure android --prepare --abi "$ABI" ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log"
206 result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")"
207 echo "### android dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY"
208
209 - name: Cache statistics
210 if: success() || failure()
211 continue-on-error: true
212 run: |
213 {
214 echo '```'
215 ccache --show-stats
216 echo '```'
217 } >> "$GITHUB_STEP_SUMMARY"
218
219 ios:
220 name: iOS simulator
221 if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios')
222 runs-on: blacksmith-12vcpu-macos-26
223 timeout-minutes: 60
224 concurrency:
225 group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }}
226 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
227 env:
228 NODE_OPTIONS: --max-old-space-size=8192
229 EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }}
230 T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }}
231 # Gradle and Xcode output goes to the job log, not a file on the runner.
232 T3_NATIVE_CLIENT_VERBOSE: "1"
233 steps:
234 - name: Checkout
235 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
236 with:
237 ref: ${{ inputs.ref || github.sha }}
238
239 # A dispatch names any commit GitHub has, including a fork's. Only a commit that
240 # collaborators pushed here builds with the cache login and the shared disk.
241 - name: Check the commit belongs to this repository
242 if: github.event_name == 'workflow_dispatch'
243 env:
244 GH_TOKEN: ${{ github.token }}
245 run: |
246 sha="$(git rev-parse HEAD)"
247 if git ls-remote --heads origin | grep -q "^$sha"; then
248 exit 0
249 fi
250 status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)"
251 if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then
252 exit 0
253 fi
254 echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally."
255 exit 1
256
257 # The build runs dependency scripts; it gets no token. checkout keeps the token in a
258 # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials:
259 # false` fails here: its cleanup runs `git submodule foreach`, which stops at the
260 # vendored gitlinks under .repos (no .gitmodules entry).
261 - name: Drop checkout credentials
262 run: |
263 git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do
264 rm -f "$file"
265 git config --local --unset-all "$key"
266 done
267 git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do
268 git config --local --unset-all "$key"
269 done
270 rm -f "$RUNNER_TEMP"/git-credentials-*.config
271 if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then
272 echo "::error::checkout credentials are still present"
273 exit 1
274 fi
275
276 - name: Setup dependencies
277 uses: ./.github/actions/setup-ci-deps
278 with:
279 task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}
280 install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts...
281
282 # Only this login can write to the cache; pull requests never get it.
283 - id: login
284 name: Log in to the native client cache
285 if: github.event_name != 'pull_request'
286 env:
287 WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }}
288 run: |
289 if [ -z "$WRITE_TOKEN" ]; then
290 echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built."
291 exit 0
292 fi
293 node --input-type=module <<'NODE'
294 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
295 import { homedir } from "node:os";
296 import { join } from "node:path";
297 const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8"));
298 const directory = join(homedir(), ".config/t3-task-cache");
299 mkdirSync(directory, { recursive: true, mode: 0o700 });
300 writeFileSync(
301 join(directory, "credentials.json"),
302 JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }),
303 { mode: 0o600 },
304 );
305 NODE
306 echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV"
307 echo "ready=true" >> "$GITHUB_OUTPUT"
308
309 - name: Build and share
310 if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true'
311 shell: bash # pipefail: a failed build fails the step through tee
312 run: |
313 node scripts/mobile-native-client.ts ensure ios --prepare ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log"
314 result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")"
315 echo "### ios dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY"