mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
* feat(mobile): one build-time constant decides native or OTA, default native EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in preferences.ts. Expo's babel preset inlines a literal process.env member expression at build time, so a release bundle carries the answer as a constant and anything but the exact string 'ota' — unset, empty, a typo — is native. Every default build is therefore the native app, unchanged. mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to mount the page comes from the build and never from storage: a native binary installed over an OTA one, same bundle id and same data container, still refuses a stored 'true' without reading the key. An unset key reads on only in an OTA build; a development build keeps its opt-in, and a stored 'false' wins everywhere so the Troubleshoot toggle can switch an OTA build back to native. That toggle now mounts wherever the flag can be on, which is the only way back to the native screens in an OTA build, and its label names the build kind rather than saying "(dev)". The bundle probe row beside it stays development-only: it fetches. The flag census gains two rules — one module reads the switch, in the member form Expo inlines and not the bracket form, and one named function answers the build kind — and the build-kind fence now lists the Troubleshoot route that asks it. Docblocks that said a store build can never mount the shell now say it mounts only when built for OTA. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): one workflow input picks the shell, and no input means native Both release workflows gain a `shell` workflow_dispatch choice, options native and ota, default native, and hand it to the step that bundles the JavaScript as EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and the fastlane build_and_upload step on iOS; nothing else in either file sets it. A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'` yields native for them — the first OTA release is a dispatch with one field changed, and every other run is the app we ship today. Each build step prints the value it is about to build with, read back from the same variable rather than from a second copy of the expression, so a run's log cannot claim a shell the build did not use. The new contract test evaluates that expression rather than matching its text: absent, empty and 'native' all resolve to native, 'ota' to ota, and any expression shape it cannot evaluate is a failure rather than a pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build: the desktop packages the real page, and the placeholder is retired build:mobile-web now runs the app builder and app verifier, and both take their output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each carrying a constant of their own — one definition of where the bundle lives, so a drift cannot leave electron-builder's beforePack looking at an empty directory while the builder reports a tree it wrote elsewhere. build:mobile-web:app is gone; it was the same two commands. src/mobile-web/ and its two scripts go with it. What the app builder shared with them is split into three modules named for what they hold rather than for the bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the canonical asset serialization, buildId, hashed assets, the protocol window and the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two failure modes are Windows paths and symlinked entries), and mobile-web-source-line-endings.mjs (the CRLF guard, now with a required directory rather than a default pointing at the deleted tree). The two suites that only needed *a* valid tree on disk — the beforePack guard and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree instead of bundling the whole mobile graph. It goes through the same manifest writer the page does, so a manifest shape change still reaches them. Also retired: the placeholder's tsconfig project and its typecheck lane, its knip entry, its electron-builder exclusion and .gitattributes pins, and the app-bundle test that asserted the shims stayed out of a builder that no longer exists. pr.yml's page job builds the same bundle the package job ships. Inert for native phones: they never fetch it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): one import of node:fs/promises in the entry-detection suite The changed-code quality gate's focused plugins read the two as a duplicate import; the readFile line was left over from the split. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: the comments that still describe the retired placeholder bundle The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app and shipped by nothing. That script, that directory and that fact are all gone: it is built by `build:mobile-web` into the packaged bundle dir, and a phone mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota. Two Windows cache keys explained themselves by naming src/mobile-web and "the two bundle builders"; config/** now covers the builder, the verifier and the manifest writer, and the spike's key no longer waits on a Phase C flip that has happened. The keys themselves are unchanged. Three scratch directories in the app-bundle suites and one in the verifier still spelled the retired output root. Renamed to mobile-web, which is what the build writes; they are temp subdirectory names and nothing reads them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
212 lines
8.3 KiB
YAML
212 lines
8.3 KiB
YAML
name: Mobile iOS Release
|
||
|
||
# Why a separate workflow from Android: iOS releases go through App Store
|
||
# review, which can take days. Decoupling the triggers lets an Android release
|
||
# ship immediately without waiting on iOS, and vice versa.
|
||
on:
|
||
push:
|
||
tags:
|
||
- 'mobile-ios-v*'
|
||
workflow_dispatch:
|
||
inputs:
|
||
bump_patch_version:
|
||
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
|
||
required: false
|
||
default: false
|
||
type: boolean
|
||
release_version:
|
||
description: 'Optional exact iOS marketing version override, e.g. 0.0.15'
|
||
required: false
|
||
type: string
|
||
testflight_changelog:
|
||
description: 'Optional TestFlight external tester changelog'
|
||
required: false
|
||
type: string
|
||
shell:
|
||
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
||
required: false
|
||
default: native
|
||
type: choice
|
||
options:
|
||
- native
|
||
- ota
|
||
|
||
jobs:
|
||
ios-build:
|
||
# GitHub-hosted macOS runner: required for Xcode. Expo SDK 55's
|
||
# expo-modules-core declares swift_version 6.0 and uses Swift 6 syntax
|
||
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
|
||
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
|
||
runs-on: macos-26
|
||
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
|
||
timeout-minutes: 90
|
||
|
||
env:
|
||
# Why: this job and ios-distribute resolve gems ~25 minutes apart, so both
|
||
# must install the committed Gemfile.lock exactly. Frozen turns a lockfile
|
||
# drift into a setup failure instead of two different fastlane versions in
|
||
# one release.
|
||
BUNDLE_FROZEN: 'true'
|
||
|
||
outputs:
|
||
release_version: ${{ steps.release_metadata.outputs.version }}
|
||
build_number: ${{ steps.release_metadata.outputs.build_number }}
|
||
|
||
defaults:
|
||
run:
|
||
working-directory: mobile
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Select Xcode
|
||
uses: maxim-lobanov/setup-xcode@v1
|
||
with:
|
||
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
|
||
xcode-version: '26.5'
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version: 24
|
||
|
||
- name: Setup pnpm
|
||
uses: pnpm/setup@v2
|
||
with:
|
||
install: false
|
||
|
||
- name: Install dependencies
|
||
run: pnpm install --frozen-lockfile
|
||
|
||
- name: Setup Ruby and fastlane
|
||
uses: ruby/setup-ruby@v1
|
||
with:
|
||
ruby-version: '3.3'
|
||
bundler-cache: true
|
||
working-directory: mobile
|
||
|
||
- name: Resolve release version and build number
|
||
env:
|
||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||
MOBILE_IOS_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||
MOBILE_IOS_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
|
||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||
FASTLANE_HIDE_CHANGELOG: '1'
|
||
run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION"
|
||
|
||
- name: Capture release metadata
|
||
id: release_metadata
|
||
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
|
||
|
||
- name: Expo prebuild
|
||
env:
|
||
ORCA_IOS_APS_ENVIRONMENT: production
|
||
run: npx expo prebuild --platform ios --no-install
|
||
|
||
- name: Install CocoaPods
|
||
run: npx pod-install ios
|
||
|
||
# Why: `-allowProvisioningUpdates` + the App Store Connect API key can
|
||
# create/refresh provisioning profiles, but it cannot recreate the
|
||
# distribution certificate's PRIVATE KEY across runs. So we import a
|
||
# pre-exported distribution .p12 (created once via Apple Developer) into a
|
||
# throwaway keychain. The keychain is ephemeral to the runner and torn
|
||
# down with the VM; nothing secret is written to the repo.
|
||
- name: Import distribution certificate
|
||
env:
|
||
IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
|
||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||
run: |
|
||
set -euo pipefail
|
||
KEYCHAIN_PATH="$RUNNER_TEMP/orca-signing.keychain-db"
|
||
# Random per-run keychain password; never persisted.
|
||
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
|
||
CERT_PATH="$RUNNER_TEMP/orca-dist-cert.p12"
|
||
|
||
echo "$IOS_DIST_CERT_P12" | base64 --decode > "$CERT_PATH"
|
||
|
||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||
security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \
|
||
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||
# Allow codesign/xcodebuild to use the key without an interactive prompt.
|
||
security set-key-partition-list -S apple-tool:,apple: \
|
||
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
|
||
# Put our keychain in the search list so xcodebuild can find the identity.
|
||
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
|
||
rm -f "$CERT_PATH"
|
||
|
||
- name: Build and upload to TestFlight
|
||
env:
|
||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||
# Keep fastlane non-interactive and quiet about analytics in CI.
|
||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||
FASTLANE_HIDE_CHANGELOG: '1'
|
||
# The one build-time constant that decides whether this binary mounts the web page or
|
||
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
||
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
||
run: |
|
||
set -euo pipefail
|
||
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
||
bundle exec fastlane ios build_and_upload
|
||
|
||
- name: Upload .ipa artifact
|
||
if: always()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-mobile-ipa
|
||
path: mobile/build/*.ipa
|
||
if-no-files-found: ignore
|
||
|
||
ios-distribute:
|
||
name: Distribute to external TestFlight testers
|
||
needs: ios-build
|
||
runs-on: ubuntu-latest
|
||
# Fastlane's processing wait fails after 20m; this outer bound leaves setup
|
||
# margin without allowing App Store Connect polling to hang for hours.
|
||
timeout-minutes: 30
|
||
|
||
env:
|
||
# Same fastlane as ios-build, or fail before touching App Store Connect.
|
||
BUNDLE_FROZEN: 'true'
|
||
|
||
defaults:
|
||
run:
|
||
working-directory: mobile
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Setup Ruby and fastlane
|
||
uses: ruby/setup-ruby@v1
|
||
with:
|
||
ruby-version: '3.3'
|
||
bundler-cache: true
|
||
working-directory: mobile
|
||
|
||
- name: Wait for processing and distribute to peeps
|
||
env:
|
||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
|
||
MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }}
|
||
MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }}
|
||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||
FASTLANE_HIDE_CHANGELOG: '1'
|
||
run: |
|
||
if ! bundle exec fastlane ios distribute_testflight \
|
||
version:"$MOBILE_IOS_RELEASE_VERSION" \
|
||
build_number:"$MOBILE_IOS_BUILD_NUMBER"; then
|
||
echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above."
|
||
exit 1
|
||
fi
|