diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 00000000..352e06aa --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,59 @@ +name: Mobile Smoke Build + +on: + workflow_dispatch: + +concurrency: + group: mobile-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-debug: + name: Android debug APK + runs-on: ubuntu-latest + 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: Type-check mobile package + run: bun run type-check:mobile + + - name: Lint mobile package + run: bun run lint:mobile + + - name: Build Android debug APK + run: bun run mobile:build:android:debug + + - name: Upload Android debug APK + uses: actions/upload-artifact@v4 + with: + name: openchamber-android-debug-apk + path: packages/mobile/android/app/build/outputs/apk/debug/*.apk + if-no-files-found: error + + ios-simulator: + name: iOS simulator app + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install + + - name: Build iOS simulator app + run: bun run mobile:build:ios:simulator diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 00000000..c17687b2 --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,279 @@ +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 + upload_github_release: + description: Upload Android artifacts to the matching GitHub Release when running on a tag. + required: false + default: true + type: boolean + push: + tags: + - "v*" + +concurrency: + group: mobile-release-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + 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='${{ github.event.inputs.version_name }}' + input_build='${{ github.event.inputs.build_number }}' + package_version="$(node -p "require('./package.json').version")" + + version_name="${input_version:-$package_version}" + build_number="${input_build:-${{ github.run_number }}}" + release_tag="" + if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then + release_tag="$GITHUB_REF_NAME" + fi + + echo "version_name=$version_name" >> "$GITHUB_OUTPUT" + echo "build_number=$build_number" >> "$GITHUB_OUTPUT" + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + + android-release: + name: Android signed release + 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: needs.resolve-version.outputs.release_tag != '' && (github.event_name != 'workflow_dispatch' || github.event.inputs.upload_github_release == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} + shell: bash + run: | + set -euo pipefail + files=( + app/build/outputs/bundle/release/*.aab + app/build/outputs/apk/release/*.apk + ) + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + working-directory: ${{ env.ANDROID_PROJECT_DIR }} + + ios-testflight: + name: iOS TestFlight upload + runs-on: macos-15 + 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" + + printf '%s' "$IOS_APP_PROFILE_BASE64" | base64 -D > "$profiles_dir/openchamber-app.mobileprovision" + printf '%s' "$IOS_WIDGET_PROFILE_BASE64" | base64 -D > "$profiles_dir/openchamber-widget.mobileprovision" + printf '%s' "$IOS_NSE_PROFILE_BASE64" | base64 -D > "$profiles_dir/openchamber-notification-service.mobileprovision" + + - 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 }} + 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" + working-directory: ${{ env.IOS_PROJECT_DIR }} + + - name: Archive iOS app + shell: bash + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + xcodebuild archive \ + -workspace App.xcworkspace \ + -scheme App \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/OpenChamber.xcarchive" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + "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" diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index d86e8dd7..8a488d6b 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -1,5 +1,13 @@ apply plugin: 'com.android.application' +def ciVersionCode = System.getenv('OPENCHAMBER_ANDROID_VERSION_CODE') +def ciVersionName = System.getenv('OPENCHAMBER_ANDROID_VERSION_NAME') +def ciKeystorePath = System.getenv('OPENCHAMBER_ANDROID_KEYSTORE_PATH') +def ciKeystorePassword = System.getenv('OPENCHAMBER_ANDROID_KEYSTORE_PASSWORD') +def ciKeyAlias = System.getenv('OPENCHAMBER_ANDROID_KEY_ALIAS') +def ciKeyPassword = System.getenv('OPENCHAMBER_ANDROID_KEY_PASSWORD') +def hasCiSigning = ciKeystorePath && ciKeystorePassword && ciKeyAlias && ciKeyPassword + android { namespace "com.openchamber.app" compileSdk rootProject.ext.compileSdkVersion @@ -7,16 +15,29 @@ android { applicationId "com.openchamber.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode ciVersionCode ? ciVersionCode.toInteger() : 1 + versionName ciVersionName ?: "1.0" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + release { + if (hasCiSigning) { + storeFile file(ciKeystorePath) + storePassword ciKeystorePassword + keyAlias ciKeyAlias + keyPassword ciKeyPassword + } + } + } buildTypes { release { + if (hasCiSigning) { + signingConfig signingConfigs.release + } minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' }