name: Mobile Release on: workflow_dispatch: inputs: version_name: description: Version name / marketing version. Leave empty to use package.json version. required: false type: string build_number: description: Build number. Leave empty to use GitHub run number. required: false type: string release_tag: description: Existing GitHub Release tag for Android artifact upload, for example v1.14.1. required: false type: string upload_github_release: description: Upload Android artifacts to GitHub Release. Requires release_tag when called by the release workflow. required: false default: false type: boolean build_android: description: Build Android signed APK/AAB artifacts. required: false default: true type: boolean build_ios: description: Build iOS IPA and upload it to TestFlight. required: false default: true type: boolean workflow_call: inputs: version_name: description: Version name / marketing version. Leave empty to use package.json version. required: false type: string build_number: description: Build number. Leave empty to use GitHub run number. required: false type: string release_tag: description: Existing GitHub Release tag to attach Android artifacts to. required: false type: string upload_github_release: description: Upload Android artifacts to the matching GitHub Release. required: false default: false type: boolean build_android: description: Build Android signed APK/AAB artifacts. required: false default: true type: boolean build_ios: description: Build iOS IPA and upload it to TestFlight. required: false default: true type: boolean concurrency: group: mobile-release-${{ inputs.release_tag != '' && inputs.release_tag || github.run_id }} cancel-in-progress: false env: MOBILE_PACKAGE_DIR: packages/mobile IOS_PROJECT_DIR: packages/mobile/ios/App ANDROID_PROJECT_DIR: packages/mobile/android jobs: resolve-version: name: Resolve mobile version runs-on: ubuntu-latest outputs: version_name: ${{ steps.version.outputs.version_name }} build_number: ${{ steps.version.outputs.build_number }} release_tag: ${{ steps.version.outputs.release_tag }} steps: - uses: actions/checkout@v4 - name: Resolve version values id: version shell: bash run: | set -euo pipefail input_version='${{ inputs.version_name }}' input_build='${{ inputs.build_number }}' input_release_tag='${{ inputs.release_tag }}' build_android='${{ inputs.build_android }}' build_ios='${{ inputs.build_ios }}' package_version="$(node -p "require('./package.json').version")" if [[ "$build_android" != "true" && "$build_ios" != "true" ]]; then echo "Select at least one platform: build_android or build_ios." exit 1 fi version_name="${input_version:-$package_version}" build_number="${input_build:-${{ github.run_number }}}" release_tag="$input_release_tag" { echo "version_name=$version_name" echo "build_number=$build_number" echo "release_tag=$release_tag" } >> "$GITHUB_OUTPUT" android-release: name: Android signed release if: inputs.build_android runs-on: ubuntu-latest needs: resolve-version permissions: contents: write steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.14 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Install dependencies run: bun install - name: Prepare Android keystore shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | set -euo pipefail if [[ -z "$ANDROID_KEYSTORE_BASE64" ]]; then echo "ANDROID_KEYSTORE_BASE64 secret is required." exit 1 fi echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/openchamber-release.keystore" - name: Build signed Android release env: OPENCHAMBER_ANDROID_VERSION_CODE: ${{ needs.resolve-version.outputs.build_number }} OPENCHAMBER_ANDROID_VERSION_NAME: ${{ needs.resolve-version.outputs.version_name }} OPENCHAMBER_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/openchamber-release.keystore OPENCHAMBER_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} OPENCHAMBER_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} OPENCHAMBER_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | bun run mobile:sync ./packages/mobile/android/gradlew -p packages/mobile/android bundleRelease assembleRelease - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: openchamber-android-${{ needs.resolve-version.outputs.version_name }}-${{ needs.resolve-version.outputs.build_number }} path: | packages/mobile/android/app/build/outputs/bundle/release/*.aab packages/mobile/android/app/build/outputs/apk/release/*.apk if-no-files-found: error - name: Upload Android artifacts to GitHub Release if: inputs.upload_github_release && needs.resolve-version.outputs.release_tag != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} VERSION_NAME: ${{ needs.resolve-version.outputs.version_name }} BUILD_NUMBER: ${{ needs.resolve-version.outputs.build_number }} shell: bash run: | set -euo pipefail mkdir -p release-assets cp app/build/outputs/bundle/release/*.aab "release-assets/OpenChamber-${VERSION_NAME}-${BUILD_NUMBER}-android.aab" cp app/build/outputs/apk/release/*.apk "release-assets/OpenChamber-${VERSION_NAME}-${BUILD_NUMBER}-android.apk" files=( app/build/outputs/bundle/release/*.aab app/build/outputs/apk/release/*.apk release-assets/* ) gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" working-directory: ${{ env.ANDROID_PROJECT_DIR }} ios-testflight: name: iOS TestFlight upload if: inputs.build_ios runs-on: macos-26 needs: resolve-version steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.14 - name: Install dependencies run: bun install - name: Install Apple signing assets shell: bash env: IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} IOS_APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROFILE_BASE64 }} IOS_WIDGET_PROFILE_BASE64: ${{ secrets.IOS_WIDGET_PROFILE_BASE64 }} IOS_NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROFILE_BASE64 }} run: | set -euo pipefail for name in IOS_DISTRIBUTION_CERTIFICATE_BASE64 IOS_APP_PROFILE_BASE64 IOS_WIDGET_PROFILE_BASE64 IOS_NSE_PROFILE_BASE64; do if [[ -z "${!name}" ]]; then echo "$name secret is required." exit 1 fi done cert_path="$RUNNER_TEMP/ios_distribution.p12" keychain_path="$RUNNER_TEMP/app-signing.keychain-db" profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles" mkdir -p "$profiles_dir" printf '%s' "$IOS_DISTRIBUTION_CERTIFICATE_BASE64" | base64 -D > "$cert_path" security create-keychain -p "$RUNNER_TEMP" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$RUNNER_TEMP" "$keychain_path" security import "$cert_path" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" security list-keychain -d user -s "$keychain_path" app_profile="$RUNNER_TEMP/openchamber-app.mobileprovision" widget_profile="$RUNNER_TEMP/openchamber-widget.mobileprovision" nse_profile="$RUNNER_TEMP/openchamber-notification-service.mobileprovision" printf '%s' "$IOS_APP_PROFILE_BASE64" | base64 -D > "$app_profile" printf '%s' "$IOS_WIDGET_PROFILE_BASE64" | base64 -D > "$widget_profile" printf '%s' "$IOS_NSE_PROFILE_BASE64" | base64 -D > "$nse_profile" profile_uuid() { security cms -D -i "$1" > "$RUNNER_TEMP/profile.plist" /usr/libexec/PlistBuddy -c 'Print :UUID' "$RUNNER_TEMP/profile.plist" } install_profile() { local source_path="$1" local env_name="$2" local uuid uuid="$(profile_uuid "$source_path")" cp "$source_path" "$profiles_dir/$uuid.mobileprovision" echo "$env_name=$uuid" >> "$GITHUB_ENV" } install_profile "$app_profile" IOS_APP_PROFILE_UUID install_profile "$widget_profile" IOS_WIDGET_PROFILE_UUID install_profile "$nse_profile" IOS_NSE_PROFILE_UUID - name: Prepare mobile assets run: bun run mobile:sync - name: Set TestFlight entitlement and versions shell: bash env: VERSION_NAME: ${{ needs.resolve-version.outputs.version_name }} BUILD_NUMBER: ${{ needs.resolve-version.outputs.build_number }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} IOS_APP_PROFILE_NAME: ${{ secrets.IOS_APP_PROFILE_NAME }} IOS_WIDGET_PROFILE_NAME: ${{ secrets.IOS_WIDGET_PROFILE_NAME }} IOS_NSE_PROFILE_NAME: ${{ secrets.IOS_NSE_PROFILE_NAME }} run: | set -euo pipefail /usr/libexec/PlistBuddy -c "Set :aps-environment production" App/App.entitlements xcrun agvtool new-marketing-version "$VERSION_NAME" xcrun agvtool new-version -all "$BUILD_NUMBER" node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from 'node:fs'; const projectPath = 'App.xcodeproj/project.pbxproj'; let project = readFileSync(projectPath, 'utf8'); const releaseBlockPattern = /\n\t\t[^\n]+ \/\* Release \*\/ = \{\n\t\t\tisa = XCBuildConfiguration;[\s\S]*?\n\t\t\tname = Release;\n\t\t\};/g; const replacements = [ { bundle: 'com.openchamber.app', profile: process.env.IOS_APP_PROFILE_NAME, uuid: process.env.IOS_APP_PROFILE_UUID, }, { bundle: 'com.openchamber.app.OpenChamberWidget', profile: process.env.IOS_WIDGET_PROFILE_NAME, uuid: process.env.IOS_WIDGET_PROFILE_UUID, }, { bundle: 'com.openchamber.app.OpenChamberNotificationService', profile: process.env.IOS_NSE_PROFILE_NAME, uuid: process.env.IOS_NSE_PROFILE_UUID, }, ]; function setBuildSetting(block, key, value) { const settingPattern = new RegExp(`\\n\\t\\t\\t\\t${key} = [^;]+;`); const line = `\n\t\t\t\t${key} = ${value};`; if (settingPattern.test(block)) return block.replace(settingPattern, line); return block.replace('\n\t\t\t};', `${line}\n\t\t\t};`); } for (const { bundle, profile, uuid } of replacements) { if (!profile) throw new Error(`Missing provisioning profile name for ${bundle}`); if (!uuid) throw new Error(`Missing provisioning profile UUID for ${bundle}`); const marker = `PRODUCT_BUNDLE_IDENTIFIER = ${bundle};`; const match = [...project.matchAll(releaseBlockPattern)].find(([block]) => block.includes(marker)); if (!match) throw new Error(`Could not find ${bundle} Release build settings block`); let block = match[0]; block = setBuildSetting(block, 'CODE_SIGN_IDENTITY', '"Apple Distribution"'); block = setBuildSetting(block, 'CODE_SIGN_STYLE', 'Manual'); block = setBuildSetting(block, 'DEVELOPMENT_TEAM', process.env.APPLE_TEAM_ID); block = setBuildSetting(block, 'PROVISIONING_PROFILE', `"${uuid}"`); block = setBuildSetting(block, 'PROVISIONING_PROFILE_SPECIFIER', `"${profile}"`); project = project.replace(match[0], block); } writeFileSync(projectPath, project); NODE working-directory: ${{ env.IOS_PROJECT_DIR }} - name: Archive iOS app shell: bash run: | set -euo pipefail xcodebuild archive \ -workspace App.xcworkspace \ -scheme App \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$RUNNER_TEMP/OpenChamber.xcarchive" \ "OTHER_CODE_SIGN_FLAGS=--keychain $RUNNER_TEMP/app-signing.keychain-db" working-directory: ${{ env.IOS_PROJECT_DIR }} - name: Export IPA shell: bash env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} IOS_APP_PROFILE_NAME: ${{ secrets.IOS_APP_PROFILE_NAME }} IOS_WIDGET_PROFILE_NAME: ${{ secrets.IOS_WIDGET_PROFILE_NAME }} IOS_NSE_PROFILE_NAME: ${{ secrets.IOS_NSE_PROFILE_NAME }} run: | set -euo pipefail for name in IOS_APP_PROFILE_NAME IOS_WIDGET_PROFILE_NAME IOS_NSE_PROFILE_NAME; do if [[ -z "${!name}" ]]; then echo "$name secret is required." exit 1 fi done cat > "$RUNNER_TEMP/ExportOptions.plist" < method app-store teamID $APPLE_TEAM_ID signingStyle manual provisioningProfiles com.openchamber.app $IOS_APP_PROFILE_NAME com.openchamber.app.OpenChamberWidget $IOS_WIDGET_PROFILE_NAME com.openchamber.app.OpenChamberNotificationService $IOS_NSE_PROFILE_NAME uploadSymbols PLIST xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/OpenChamber.xcarchive" \ -exportPath "$RUNNER_TEMP/OpenChamberExport" \ -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" working-directory: ${{ env.IOS_PROJECT_DIR }} - name: Upload IPA artifact uses: actions/upload-artifact@v4 with: name: openchamber-ios-${{ needs.resolve-version.outputs.version_name }}-${{ needs.resolve-version.outputs.build_number }} path: ${{ runner.temp }}/OpenChamberExport/*.ipa if-no-files-found: error - name: Upload to TestFlight shell: bash env: APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY_BASE64 }} run: | set -euo pipefail mkdir -p "$HOME/private_keys" printf '%s' "$APP_STORE_CONNECT_PRIVATE_KEY_BASE64" | base64 -D > "$HOME/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" xcrun altool --upload-app \ --type ios \ --file "$RUNNER_TEMP/OpenChamberExport/App.ipa" \ --apiKey "$APP_STORE_CONNECT_KEY_ID" \ --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"