Skip to content

Commit a353ebb

Browse files
authored
build(mobile): run local EAS releases on GitHub Actions (#378)
* build(mobile): add local EAS release workflow * fix(mobile): read PostHog host from Actions vars * build(mobile): configure App Store Connect app
1 parent 200753c commit a353ebb

6 files changed

Lines changed: 273 additions & 11 deletions

File tree

.github/workflows/build-mobile.yml

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Production mobile binaries are compiled on GitHub runners with EAS Build's local engine.
2+
# EAS remains the credential store and, when requested, uploads the signed artifacts to the stores.
3+
4+
name: Build Mobile
5+
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
submit:
10+
description: Upload the completed builds to TestFlight and Google Play internal testing
11+
type: boolean
12+
required: true
13+
default: false
14+
15+
concurrency:
16+
group: build-mobile-production
17+
cancel-in-progress: false
18+
19+
permissions:
20+
contents: read
21+
22+
env:
23+
EAS_CLI_VERSION: 21.4.0
24+
MOBILE_DIR: apps/mobile
25+
NODE_OPTIONS: --max-old-space-size=4096
26+
27+
jobs:
28+
preflight:
29+
name: Validate release request
30+
runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }}
31+
timeout-minutes: 5
32+
steps:
33+
- name: Checkout
34+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
35+
36+
- name: Check submit configuration
37+
if: ${{ inputs.submit }}
38+
run: |
39+
asc_app_id="$(jq -r '.submit.production.ios.ascAppId // empty' apps/mobile/eas.json)"
40+
if [ -z "$asc_app_id" ]; then
41+
echo "::error::Set submit.production.ios.ascAppId in apps/mobile/eas.json before enabling submit"
42+
exit 1
43+
fi
44+
45+
build:
46+
name: Build ${{ matrix.platform }}
47+
needs: preflight
48+
runs-on: ${{ matrix.os }}
49+
timeout-minutes: 120
50+
environment: release
51+
strategy:
52+
fail-fast: false
53+
matrix:
54+
include:
55+
- platform: android
56+
extension: aab
57+
os: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }}
58+
- platform: ios
59+
extension: ipa
60+
os: ${{ vars.CI_RUNNER_MACOS || 'macos-26' }}
61+
62+
steps:
63+
- name: Check release configuration
64+
shell: bash
65+
env:
66+
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
67+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
68+
SENTRY_DSN_MOBILE: ${{ secrets.SENTRY_DSN_MOBILE }}
69+
POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
70+
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
71+
run: |
72+
set -euo pipefail
73+
missing=false
74+
for variable in EXPO_TOKEN SENTRY_AUTH_TOKEN SENTRY_DSN_MOBILE POSTHOG_PROJECT_TOKEN POSTHOG_HOST; do
75+
if [ -z "${!variable}" ]; then
76+
echo "::error::Missing GitHub Actions secret or variable: ${variable}"
77+
missing=true
78+
fi
79+
done
80+
if [ "$missing" = true ]; then
81+
exit 1
82+
fi
83+
84+
- name: Checkout
85+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
86+
87+
- name: Setup pnpm
88+
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
89+
with:
90+
run_install: false
91+
cache: true
92+
93+
- name: Setup Node
94+
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
95+
with:
96+
node-version-file: .nvmrc
97+
package-manager-cache: false
98+
99+
- name: Setup Java
100+
if: ${{ matrix.platform == 'android' }}
101+
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
102+
with:
103+
distribution: temurin
104+
java-version: 17
105+
106+
- name: Setup EAS
107+
uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8
108+
with:
109+
eas-version: ${{ env.EAS_CLI_VERSION }}
110+
packager: pnpm
111+
token: ${{ secrets.EXPO_TOKEN }}
112+
113+
- name: Verify native toolchain
114+
shell: bash
115+
run: |
116+
set -euo pipefail
117+
if [ "${{ matrix.platform }}" = ios ]; then
118+
xcodebuild -version
119+
pod --version
120+
fastlane --version
121+
read -r _ xcode_version < <(xcodebuild -version)
122+
xcode_major="${xcode_version%%.*}"
123+
if [ "$xcode_major" -lt 26 ]; then
124+
echo "::error::Xcode 26 or newer is required; found ${xcode_version}"
125+
exit 1
126+
fi
127+
else
128+
java -version
129+
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME" ]; then
130+
echo "::error::ANDROID_HOME does not point to an installed Android SDK"
131+
exit 1
132+
fi
133+
fi
134+
135+
- name: Install dependencies
136+
run: pnpm install --frozen-lockfile
137+
138+
- name: Build signed artifact locally
139+
working-directory: ${{ env.MOBILE_DIR }}
140+
env:
141+
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN_MOBILE }}
142+
EXPO_PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
143+
EXPO_PUBLIC_POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
144+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
145+
run: eas build --local --platform "${{ matrix.platform }}" --profile production --output "$RUNNER_TEMP/linkcode-${{ matrix.platform }}.${{ matrix.extension }}" --non-interactive
146+
147+
- name: Upload artifact
148+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
149+
with:
150+
name: mobile-${{ matrix.platform }}
151+
path: ${{ runner.temp }}/linkcode-${{ matrix.platform }}.${{ matrix.extension }}
152+
if-no-files-found: error
153+
retention-days: 7
154+
155+
submit:
156+
name: Submit ${{ matrix.platform }}
157+
if: ${{ inputs.submit }}
158+
needs: build
159+
runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }}
160+
timeout-minutes: 30
161+
environment: release
162+
strategy:
163+
fail-fast: false
164+
matrix:
165+
include:
166+
- platform: android
167+
extension: aab
168+
- platform: ios
169+
extension: ipa
170+
171+
steps:
172+
- name: Checkout
173+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
174+
175+
- name: Setup pnpm
176+
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
177+
with:
178+
run_install: false
179+
cache: true
180+
181+
- name: Setup Node
182+
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
183+
with:
184+
node-version-file: .nvmrc
185+
package-manager-cache: false
186+
187+
- name: Setup EAS
188+
uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8
189+
with:
190+
eas-version: ${{ env.EAS_CLI_VERSION }}
191+
packager: pnpm
192+
token: ${{ secrets.EXPO_TOKEN }}
193+
194+
- name: Install dependencies
195+
run: pnpm install --frozen-lockfile
196+
197+
- name: Download artifact
198+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
199+
with:
200+
name: mobile-${{ matrix.platform }}
201+
path: ${{ runner.temp }}
202+
203+
- name: Submit artifact
204+
working-directory: ${{ env.MOBILE_DIR }}
205+
env:
206+
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
207+
run: eas submit --platform "${{ matrix.platform }}" --profile production --path "$RUNNER_TEMP/linkcode-${{ matrix.platform }}.${{ matrix.extension }}" --non-interactive --wait

apps/mobile/AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,6 @@ thread rows and the new-thread picker name the agent in text instead.
182182
- **Sentry:** `Sentry.init({ dsn: process.env.EXPO_PUBLIC_SENTRY_DSN })` + `Sentry.wrap` on the root
183183
layout; the Expo plugin uploads source maps for org `arcbox` / project `linkcode-mobile`. Runtime
184184
reporting no-ops without a DSN. EAS profiles select Expo environments
185-
(`development`/`preview`/`production`) — set `EXPO_PUBLIC_SENTRY_DSN` there (repo secret
186-
`SENTRY_DSN_MOBILE` is the source of truth for the value). Local iOS builds keep
187-
`SENTRY_DISABLE_AUTO_UPLOAD=true` unless `SENTRY_AUTH_TOKEN` is available.
185+
(`development`/`preview`/`production`); cloud builds can read the DSN there, while
186+
`build-mobile.yml` injects repo secret `SENTRY_DSN_MOBILE` directly for local production builds.
187+
Local iOS builds keep `SENTRY_DISABLE_AUTO_UPLOAD=true` unless `SENTRY_AUTH_TOKEN` is available.

apps/mobile/app.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"backgroundColor": "#FFFFFF"
2323
},
2424
"ios": {
25+
"appleTeamId": "422ACSY6Y5",
2526
"bundleIdentifier": "com.arcboxlabs.linkcode.mobile",
2627
"buildNumber": "1",
2728
"supportsTablet": true,

apps/mobile/eas.json

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"cli": {
3-
"version": ">= 21.0.0",
4-
"appVersionSource": "local"
3+
"version": ">= 21.4.0",
4+
"appVersionSource": "remote"
55
},
66
"build": {
77
"development": {
@@ -19,11 +19,22 @@
1919
}
2020
},
2121
"production": {
22+
"autoIncrement": true,
2223
"channel": "production",
2324
"environment": "production"
2425
}
2526
},
2627
"submit": {
27-
"production": {}
28+
"production": {
29+
"android": {
30+
"track": "internal",
31+
"releaseStatus": "completed"
32+
},
33+
"ios": {
34+
"ascAppId": "6791199804",
35+
"appleTeamId": "422ACSY6Y5",
36+
"bundleIdentifier": "com.arcboxlabs.linkcode.mobile"
37+
}
38+
}
2839
}
2940
}

docs/ENVIRONMENT.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ client configuration or new build.
107107
| `RENDERER_VITE_*`, `VITE_*` | `apps/desktop/vite.renderer.config.ts` | The only prefixes exposed to desktop renderer code (`envDir` is `apps/desktop`). |
108108
| `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. |
109109
| `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. |
110+
| `POSTHOG_HOST` | `build-mobile.yml` | Organization Actions variable mapped to `EXPO_PUBLIC_POSTHOG_HOST` for the production bundle. |
110111

111112
## Release-only secrets
112113

@@ -118,7 +119,13 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz
118119
| `CSC_IDENTITY_AUTO_DISCOVERY` | `build-desktop.yml` | `false` on unsigned builds so macOS can't sign with a random keychain identity. |
119120
| `APPLE_API_KEY_BASE64``APPLE_API_KEY` | `build-desktop.yml` | The App Store Connect `.p8` is materialized to `$RUNNER_TEMP/apple_api_key.p8`; electron-builder wants a **file path**, not the key content. |
120121
| `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, `APPLE_TEAM_ID` | `build-desktop.yml` | notarytool key identity and team. |
122+
| `EXPO_TOKEN` | `build-mobile.yml` | Expo robot-user token with access to the LinkCode EAS project, managed build credentials, remote build versions, and EAS Submit. Store it in `release` only after enabling required reviewers and deployment branch/tag restrictions. |
123+
| `SENTRY_AUTH_TOKEN` | `build-mobile.yml` | Organization Actions secret that uploads production mobile source maps. Local EAS Build cannot read an EAS variable with Secret visibility, so GitHub must inject it. |
124+
| `SENTRY_DSN_MOBILE`, `POSTHOG_PROJECT_TOKEN` | `build-mobile.yml` | Mapped to the mobile `EXPO_PUBLIC_*` build-time variables. These are publishable identifiers, but the repository currently carries them as Actions secrets. |
121125
| `AZURE_PUBLISHER_NAME`, `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, `AZURE_CERTIFICATE_PROFILE` | `build-desktop.yml` | Windows Trusted Signing identifiers (not credentials, but kept as secrets so the public repo doesn't advertise the signing infrastructure). `AZURE_PUBLISHER_NAME` must match the certificate subject CN exactly. |
122126
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. |
123127
| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. |
124128
| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | `release-please.yml`, `finalize-releases.yml`, `release-desktop.yml` | Repository/org-scoped GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; the release environment also uses it for the Homebrew cask bump and the WinGet bump (install the App on `arcboxlabs/homebrew-tap` and on the `arcboxlabs/winget-pkgs` fork with contents + pull-requests write). Missing credentials fail release automation before any tag is created; only the package-manager bumps remain an optional self-skip. |
129+
130+
Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key,
131+
and the Google Play service-account key are EAS-managed credentials, not GitHub variables.

0 commit comments

Comments
 (0)