diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 21b1bb9b..fc26ad57 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -1,14 +1,16 @@ name: Mobile -# The phone app's Android build: a signed APK people install by hand. A -# `mobile-v` tag builds it at that version and attaches it to a draft -# release of the same name; a manual run builds it at the version in -# tauri.conf.json and keeps it as a workflow artifact only. +# The phone app, both platforms at one version. A `mobile-v` tag, cut +# from a commit whose tauri.conf.json says x.y.z: +# - Android: a signed APK, attached to a draft release of the same name. +# - iOS: a build uploaded to TestFlight, as .. +# A manual run builds both at tauri.conf.json's version and uploads nothing: +# the APK is kept as a workflow artifact, the iOS build is only signed. # # The mobile app is versioned apart from the desktop's `v*` tags. Android # installs one build over another only when its versionCode is higher, and # Tauri derives it from the version (major * 1000000 + minor * 1000 + patch), -# so each tag must be higher than the last. +# so each version must be higher than the last. on: push: @@ -25,24 +27,35 @@ env: NDK_VERSION: 28.2.13676358 jobs: - android: + # The version is the one in the tagged commit, so the repo always says what + # was shipped; a tag that disagrees with it is a mistake, not an override. + version: runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 - - - name: Version + - id: version run: | set -euo pipefail - if [[ "$GITHUB_REF" == refs/tags/mobile-v* ]]; then - VERSION="${GITHUB_REF_NAME#mobile-v}" - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::tag must be mobile-v.., got $GITHUB_REF_NAME" - exit 1 - fi - else - VERSION=$(jq -r .version mobile/src-tauri/tauri.conf.json) + VERSION=$(jq -r .version mobile/src-tauri/tauri.conf.json) + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tauri.conf.json's version must be .., got $VERSION" + exit 1 fi - echo "VERSION=$VERSION" >> "$GITHUB_ENV" + if [[ "$GITHUB_REF" == refs/tags/mobile-v* && "$GITHUB_REF_NAME" != "mobile-v$VERSION" ]]; then + echo "::error::$GITHUB_REF_NAME is on a commit whose tauri.conf.json says $VERSION" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + android: + needs: version + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: @@ -101,7 +114,7 @@ jobs: run: | set -euo pipefail npm ci - npx tauri android build --apk --target aarch64 --config "{\"version\":\"$VERSION\"}" + npx tauri android build --apk --target aarch64 - name: Check and name the APK run: | @@ -146,3 +159,54 @@ jobs: --notes-file .github/mobile-install.md fi gh release upload "$GITHUB_REF_NAME" dist/* --clobber --repo "$GITHUB_REPOSITORY" + + ios: + needs: version + # Xcode 26: App Store Connect takes only builds made with the iOS 26 SDK. + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: mobile/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: mobile/src-tauri + + # An App Store Connect API key signs in for Xcode: it signs the build + # with a certificate Apple keeps, and uploads it. + - name: App Store Connect key + env: + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + set -euo pipefail + if [ -z "$ASC_KEY_P8" ]; then + echo "::error::the ASC_* secrets are not set" + exit 1 + fi + printf '%s\n' "$ASC_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" + echo "ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" + + # The build number defaults to the time (scripts/testflight.sh), so it + # rises from any machine without a counter. + - name: Build and upload + working-directory: mobile + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + set -euo pipefail + npm ci + if [[ "$GITHUB_REF" == refs/tags/mobile-v* ]]; then + scripts/testflight.sh + else + scripts/testflight.sh --no-upload + fi diff --git a/mobile/README.md b/mobile/README.md index 76614912..cef404cc 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -71,7 +71,9 @@ npm run tauri ios init # once: generates src-tauri/gen/apple npm run tauri ios dev # simulator, or pick a connected device ``` -To send a build to TestFlight (Xcode signed in to an account on the team): +Releases go to TestFlight from CI (see [Releasing](#releasing)). To send one from this +machine instead, signed in to Xcode with an account on the team, or with an App Store +Connect API key in `ASC_KEY_ID`, `ASC_ISSUER_ID` and `ASC_KEY_PATH`: ```sh scripts/testflight.sh # archive, sign for the App Store, upload @@ -97,12 +99,6 @@ npm run tauri android build -- --debug --apk --target aarch64 # an installable keyboard's height to the page, which the WebView does not report edge to edge. Don't re-run `android init` over it. -To release an APK, push a `mobile-v` tag, higher than the last. `.github/workflows/mobile.yml` -builds it at that version, signs it with the release key and attaches it to a draft -release, which is never marked latest, so the desktop updater doesn't see it. People -download the APK on the phone and open it. A later one installs over it only if it's -signed with the same key and its version is higher. - The release key is in the `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD` and `ANDROID_KEY_ALIAS` secrets, with a copy kept outside GitHub. A local release build signs with it when `src-tauri/gen/android/keystore.properties` (ignored by git) names it: @@ -114,6 +110,37 @@ keyAlias=tty7 keyPassword=… ``` +### Releasing + +Both platforms ship at one version, apart from the desktop's: + +1. Raise `version` in `src-tauri/tauri.conf.json` and merge it. Each version must be higher + than the last: Android installs over a build only when its versionCode, which Tauri + derives from the version, is higher. +2. Tag that commit `mobile-v` and push the tag. + +`.github/workflows/mobile.yml` then: + +- **Android:** builds a signed arm64 APK and attaches it to a draft release, which you + publish. The release is never marked latest, because the desktop updater reads + `/releases/latest`. +- **iOS:** uploads a build to TestFlight. It reaches testers once App Store Connect has + processed it, and, for the external group, once Beta App Review passes. +- **Checks:** a tag that doesn't match `tauri.conf.json` fails the run. + +Running the workflow by hand builds both platforms but uploads nothing. + +Secrets: + +| Secret | What | +|---|---| +| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS` | the Android release key | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | an App Store Connect API key with the Admin role, which signs and uploads iOS builds (App Manager keys may not sign in the cloud) | + +Keep both keys outside GitHub as well; secrets can't be read back. A phone feature that +needs a newer desktop says so when the desktop is older, so the release notes should name +the desktop version a release needs. + ### Without a phone `crates/tty7-mobile-client/examples/probe.rs` is a phone in a shell. It pairs, prints the diff --git a/mobile/scripts/testflight.sh b/mobile/scripts/testflight.sh index a75f4292..58bad110 100755 --- a/mobile/scripts/testflight.sh +++ b/mobile/scripts/testflight.sh @@ -5,7 +5,12 @@ # --build-number N the build is .N (default: the time, yyjjjHHMM) # --no-upload export a signed .ipa into build/testflight/ instead # -# Needs Xcode signed in (Settings → Accounts) to an account on team 78SZC3DQ7B. +# Signs in one of two ways: +# - Xcode signed in (Settings → Accounts) to an account on team 78SZC3DQ7B. +# - An App Store Connect API key, as CI does: ASC_KEY_ID and ASC_ISSUER_ID set, +# and the key at ASC_KEY_PATH (default +# ~/.appstoreconnect/private_keys/AuthKey_.p8). Xcode then signs +# with a certificate Apple keeps in the cloud, so no keychain is needed. # # Why not just `tauri ios build --export-method app-store-connect`: # - The archive is signed with a development profile first, and the team has @@ -36,7 +41,15 @@ ARCHIVE=src-tauri/gen/apple/build/tty7-mobile_iOS.xcarchive OUT=build/testflight mkdir -p "$OUT" -[ -f "$PROJECT" ] || npm run tauri ios init +[ -f "$PROJECT" ] || npm run tauri ios init -- --ci + +AUTH=() +if [ -n "${ASC_KEY_ID:-}" ]; then + KEY="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8}" + [ -f "$KEY" ] || { echo "no App Store Connect key at $KEY" >&2; exit 1; } + AUTH=(-authenticationKeyPath "$KEY" -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "${ASC_ISSUER_ID:?ASC_ISSUER_ID is needed with ASC_KEY_ID}") +fi # `tauri ios init` fills the asset catalog with Tauri's placeholder icon; # put ours back so a regenerated gen/ never ships it. @@ -80,7 +93,8 @@ xcodebuild -exportArchive \ -archivePath "$ARCHIVE" \ -exportOptionsPlist "$OUT/ExportOptions.plist" \ -exportPath "$OUT" \ - -allowProvisioningUpdates + -allowProvisioningUpdates \ + ${AUTH[@]+"${AUTH[@]}"} if [ "$UPLOAD" = 1 ]; then echo "==> Uploaded $VERSION ($BUILD); it shows in TestFlight once App Store Connect has processed it" diff --git a/mobile/src-tauri/tauri.conf.json b/mobile/src-tauri/tauri.conf.json index 86a75b63..f2f3748e 100644 --- a/mobile/src-tauri/tauri.conf.json +++ b/mobile/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "tty7", - "version": "0.1.0", + "version": "0.1.1", "identifier": "dev.tty7.mobile", "build": { "beforeDevCommand": "npm run dev",