name: Mobile Internal Build on: workflow_dispatch: inputs: platform: description: Mobile platform to build required: true type: choice default: android options: - android - ios - all publish_android: description: Publish the Android APK and latest.json to the public release feed required: true type: boolean default: false android_version_name: description: Stable Android version name when publishing, such as 0.1.1 required: true type: string default: 0.1.0 permissions: contents: read id-token: write concurrency: group: ${{ inputs.publish_android && 'mobile-android-release-publish' || format('mobile-internal-build-{0}-{1}', inputs.platform, github.ref) }} cancel-in-progress: ${{ !inputs.publish_android }} jobs: android: name: Build internal Android APK if: inputs.platform == 'android' || inputs.platform == 'all' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version-file: .node-version cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Set up Go uses: actions/setup-go@v5 with: go-version: "1.26.0" cache-dependency-path: | go.work go.work.sum packages/agent/daemon/go.mod packages/agent/daemon/go.sum packages/device-link/go.mod packages/device-link/go.sum - name: Set up JDK uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Set up Android SDK uses: android-actions/setup-android@v3 - name: Install Android build dependencies shell: bash run: | sdkmanager --install \ "platform-tools" \ "platforms;android-36" \ "build-tools;36.0.0" \ "ndk;27.3.13750724" - name: Install workspace dependencies run: pnpm install --frozen-lockfile - name: Prepare stable Android signing key shell: bash env: ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} TUTTI_ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} TUTTI_ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} run: | if [[ -z "${ANDROID_RELEASE_KEYSTORE_BASE64}" || -z "${TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD}" || -z "${TUTTI_ANDROID_RELEASE_KEY_ALIAS}" || -z "${TUTTI_ANDROID_RELEASE_KEY_PASSWORD}" ]]; then echo "Stable Android release signing secrets are required." >&2 exit 1 fi signing_dir="${RUNNER_TEMP}/tutti-android-signing" keystore_path="${signing_dir}/release.keystore" mkdir -p "${signing_dir}" printf '%s' "${ANDROID_RELEASE_KEYSTORE_BASE64}" | base64 --decode > "${keystore_path}" chmod 600 "${keystore_path}" test -s "${keystore_path}" keytool -list \ -keystore "${keystore_path}" \ -storepass "${TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD}" \ -alias "${TUTTI_ANDROID_RELEASE_KEY_ALIAS}" \ >/dev/null echo "TUTTI_ANDROID_RELEASE_KEYSTORE_PATH=${keystore_path}" >> "${GITHUB_ENV}" - name: Build Android release APK env: TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} TUTTI_ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} TUTTI_ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }} TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }} run: | pnpm --filter @tutti-os/mobile android:aar cd apps/mobile/android ./gradlew app:assembleRelease - name: Verify signed Android APK shell: bash run: | signed_apk="apps/mobile/android/app/build/outputs/apk/release/app-release.apk" expected_certificate="apps/mobile/android/release-certificate.pem" build_tools="${ANDROID_HOME}/build-tools/36.0.0" test -f "${signed_apk}" "${build_tools}/zipalign" -c -p 4 "${signed_apk}" verification_output="$( "${build_tools}/apksigner" verify --verbose --print-certs "${signed_apk}" )" printf '%s\n' "${verification_output}" expected_digest="$( keytool -printcert -file "${expected_certificate}" | sed -n 's/^[[:space:]]*SHA256: //p' | tr -d ':' | tr '[:upper:]' '[:lower:]' )" actual_digest="$( printf '%s\n' "${verification_output}" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' )" if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then echo "Android APK signing certificate does not match the pinned release certificate." >&2 exit 1 fi - name: Generate APK checksum working-directory: apps/mobile/android/app/build/outputs/apk/release run: sha256sum app-release.apk > SHA256SUMS - name: Validate Android release publishing configuration if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all') env: AWS_REGION: ${{ vars.AWS_REGION }} TUTTI_ARTIFACTS_AWS_ROLE_ARN: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }} TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL }} TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }} TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }} TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }} run: | for name in AWS_REGION TUTTI_ARTIFACTS_AWS_ROLE_ARN TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX; do if [[ -z "${!name:-}" ]]; then echo "Missing required Android release publishing variable: ${name}" >&2 exit 1 fi done if [[ ! "${TUTTI_ANDROID_VERSION_NAME}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "android_version_name must be stable semver x.y.z." >&2 exit 1 fi - name: Build Android release pointer if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all') env: TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }} TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }} TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL }} run: | node tools/scripts/build-mobile-release-latest.mjs \ --apk apps/mobile/android/app/build/outputs/apk/release/app-release.apk \ --base-url "${TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL}" \ --output mobile-release-latest.json \ --tag "tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}" \ --version-code "${TUTTI_ANDROID_VERSION_CODE}" \ --version-name "${TUTTI_ANDROID_VERSION_NAME}" - name: Configure AWS credentials for Android release if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all') uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ${{ vars.AWS_REGION }} role-to-assume: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }} - name: Prevent Android release rollback if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all') env: TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }} TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }} TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }} TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }} run: | prefix="${TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX%/}" pointer_path="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/latest.json" candidate_tag="tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}" if aws s3 cp "${pointer_path}" current-mobile-release-pointer.json 2> current-mobile-pointer-download.err; then export RELEASE_VERSION_CODE="${TUTTI_ANDROID_VERSION_CODE}" export RELEASE_TAG="${candidate_tag}" node <<'NODE' const fs = require("node:fs"); const current = JSON.parse(fs.readFileSync("current-mobile-release-pointer.json", "utf8")); const currentCode = Number(current.versionCode); const candidateCode = Number(process.env.RELEASE_VERSION_CODE); if (!Number.isSafeInteger(currentCode) || currentCode <= 0) { throw new Error("Existing Android release pointer has an invalid versionCode."); } if (!Number.isSafeInteger(candidateCode) || candidateCode <= 0) { throw new Error("Candidate Android release has an invalid versionCode."); } if (candidateCode < currentCode) { throw new Error(`Refusing to move Android release from versionCode ${currentCode} back to ${candidateCode}.`); } if (current.tag === process.env.RELEASE_TAG && candidateCode > currentCode) { throw new Error(`Android version ${process.env.RELEASE_TAG} already has a published release.`); } if (candidateCode === currentCode && current.tag !== process.env.RELEASE_TAG) { throw new Error(`Android versionCode ${candidateCode} is already published by ${current.tag}.`); } NODE elif ! grep -Eq "(404|NoSuchKey|Not Found)" current-mobile-pointer-download.err; then cat current-mobile-pointer-download.err >&2 exit 1 fi - name: Publish Android release assets and pointer if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all') env: TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }} TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }} TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }} run: | prefix="${TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX%/}" checksum_path="apps/mobile/android/app/build/outputs/apk/release/SHA256SUMS" apk_sha256="$(awk 'NR == 1 { print $1 }' "${checksum_path}")" if [[ ! "${apk_sha256}" =~ ^[a-f0-9]{64}$ ]]; then echo "Generated Android APK checksum is invalid." >&2 exit 1 fi remote_dir="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}/${apk_sha256}" pointer_path="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/latest.json" preflight_immutable() { local source_path="$1" local destination_path="$2" local comparison_path="${RUNNER_TEMP}/$(basename "${destination_path}").published" local error_path="${comparison_path}.err" if aws s3 cp "${destination_path}" "${comparison_path}" 2> "${error_path}"; then if ! cmp --silent "${source_path}" "${comparison_path}"; then echo "Refusing to overwrite immutable Android release asset: ${destination_path}" >&2 exit 1 fi echo "Immutable Android release asset already exists with identical content: ${destination_path}" return 0 fi if grep -Eq "(404|NoSuchKey|Not Found)" "${error_path}"; then return 10 else cat "${error_path}" >&2 return 1 fi } upload_immutable() { local source_path="$1" local destination_path="$2" local content_type="$3" aws s3 cp "${source_path}" "${destination_path}" \ --cache-control "public, max-age=31536000, immutable" \ --content-type "${content_type}" } apk_needs_upload=false if preflight_immutable \ apps/mobile/android/app/build/outputs/apk/release/app-release.apk \ "${remote_dir}/app-release.apk"; then : else status="$?" if [[ "${status}" -eq 10 ]]; then apk_needs_upload=true; else exit "${status}"; fi fi checksum_needs_upload=false if preflight_immutable "${checksum_path}" "${remote_dir}/SHA256SUMS"; then : else status="$?" if [[ "${status}" -eq 10 ]]; then checksum_needs_upload=true; else exit "${status}"; fi fi if [[ "${apk_needs_upload}" == true ]]; then upload_immutable \ apps/mobile/android/app/build/outputs/apk/release/app-release.apk \ "${remote_dir}/app-release.apk" \ "application/vnd.android.package-archive" fi if [[ "${checksum_needs_upload}" == true ]]; then upload_immutable "${checksum_path}" "${remote_dir}/SHA256SUMS" "text/plain" fi aws s3 cp mobile-release-latest.json "${pointer_path}" \ --cache-control "public, max-age=60" \ --content-type "application/json" - name: Upload internal Android artifact uses: actions/upload-artifact@v4 with: name: tutti-mobile-internal-${{ github.sha }} path: | apps/mobile/android/app/build/outputs/apk/release/app-release.apk apps/mobile/android/app/build/outputs/apk/release/SHA256SUMS if-no-files-found: error retention-days: 14 ios: name: Build and upload iOS TestFlight if: inputs.platform == 'ios' || inputs.platform == 'all' runs-on: macos-26 timeout-minutes: 60 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version-file: .node-version cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Set up Go uses: actions/setup-go@v5 with: go-version: "1.26.0" cache-dependency-path: | go.work go.work.sum packages/agent/daemon/go.mod packages/agent/daemon/go.sum packages/device-link/go.mod packages/device-link/go.sum - name: Install workspace dependencies run: pnpm install --frozen-lockfile - name: Prepare App Store Connect authentication shell: bash env: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} IOS_DEVELOPMENT_TEAM: ${{ vars.IOS_DEVELOPMENT_TEAM }} run: | if [[ -z "${APPLE_API_ISSUER}" || -z "${APPLE_API_KEY_ID}" || -z "${APPLE_API_KEY_P8_BASE64}" || -z "${IOS_DEVELOPMENT_TEAM}" ]]; then echo "Apple API key secrets and IOS_DEVELOPMENT_TEAM are required." >&2 exit 1 fi private_key_dir="${RUNNER_TEMP}/tutti-ios-private-keys" private_key_path="${private_key_dir}/AuthKey_${APPLE_API_KEY_ID}.p8" mkdir -p "${private_key_dir}" node -e \ "require('node:fs').writeFileSync(process.argv[1], Buffer.from(process.env.APPLE_API_KEY_P8_BASE64, 'base64'))" \ "${private_key_path}" chmod 600 "${private_key_path}" echo "APPLE_API_KEY_PATH=${private_key_path}" >> "${GITHUB_ENV}" echo "IOS_DEVELOPMENT_TEAM=${IOS_DEVELOPMENT_TEAM}" >> "${GITHUB_ENV}" - name: Build iOS framework and install pods run: pnpm --filter @tutti-os/mobile ios:pods - name: Archive iOS app shell: bash env: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: | archive_path="${RUNNER_TEMP}/TuttiMobile.xcarchive" xcodebuild \ -workspace apps/mobile/ios/TuttiMobile.xcworkspace \ -scheme TuttiMobile \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath "${archive_path}" \ -allowProvisioningUpdates \ -authenticationKeyPath "${APPLE_API_KEY_PATH}" \ -authenticationKeyID "${APPLE_API_KEY_ID}" \ -authenticationKeyIssuerID "${APPLE_API_ISSUER}" \ DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM}" \ CODE_SIGN_STYLE=Automatic \ CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" \ clean archive echo "IOS_ARCHIVE_PATH=${archive_path}" >> "${GITHUB_ENV}" - name: Export App Store Connect IPA shell: bash env: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: | export_dir="${RUNNER_TEMP}/tutti-ios-export" export_options="${RUNNER_TEMP}/tutti-ios-export-options.plist" artifact_dir="${RUNNER_TEMP}/tutti-ios-artifact" plutil -create xml1 "${export_options}" plutil -insert destination -string export "${export_options}" plutil -insert method -string app-store-connect "${export_options}" plutil -insert signingStyle -string automatic "${export_options}" plutil -insert teamID -string "${IOS_DEVELOPMENT_TEAM}" "${export_options}" plutil -insert manageAppVersionAndBuildNumber -bool false "${export_options}" plutil -insert uploadSymbols -bool true "${export_options}" xcodebuild \ -exportArchive \ -archivePath "${IOS_ARCHIVE_PATH}" \ -exportPath "${export_dir}" \ -exportOptionsPlist "${export_options}" \ -allowProvisioningUpdates \ -authenticationKeyPath "${APPLE_API_KEY_PATH}" \ -authenticationKeyID "${APPLE_API_KEY_ID}" \ -authenticationKeyIssuerID "${APPLE_API_ISSUER}" ipa_path="$(find "${export_dir}" -maxdepth 1 -name '*.ipa' -print -quit)" test -n "${ipa_path}" mkdir -p "${artifact_dir}" cp "${ipa_path}" "${artifact_dir}/tutti-mobile-testflight.ipa" - name: Verify TestFlight IPA shell: bash run: | artifact_dir="${RUNNER_TEMP}/tutti-ios-artifact" verification_dir="${RUNNER_TEMP}/tutti-ios-verification" mkdir -p "${verification_dir}" unzip -q "${artifact_dir}/tutti-mobile-testflight.ipa" -d "${verification_dir}" app_path="$(find "${verification_dir}/Payload" -maxdepth 1 -name '*.app' -print -quit)" test -n "${app_path}" test -f "${app_path}/embedded.mobileprovision" test -f "${app_path}/main.jsbundle" codesign --verify --deep --strict "${app_path}" ( cd "${artifact_dir}" shasum -a 256 tutti-mobile-testflight.ipa > SHA256SUMS ) - name: Upload to TestFlight shell: bash env: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: | upload_dir="${RUNNER_TEMP}/tutti-ios-upload" upload_options="${RUNNER_TEMP}/tutti-ios-upload-options.plist" plutil -create xml1 "${upload_options}" plutil -insert destination -string upload "${upload_options}" plutil -insert method -string app-store-connect "${upload_options}" plutil -insert signingStyle -string automatic "${upload_options}" plutil -insert teamID -string "${IOS_DEVELOPMENT_TEAM}" "${upload_options}" plutil -insert manageAppVersionAndBuildNumber -bool false "${upload_options}" plutil -insert uploadSymbols -bool true "${upload_options}" xcodebuild \ -exportArchive \ -archivePath "${IOS_ARCHIVE_PATH}" \ -exportPath "${upload_dir}" \ -exportOptionsPlist "${upload_options}" \ -allowProvisioningUpdates \ -authenticationKeyPath "${APPLE_API_KEY_PATH}" \ -authenticationKeyID "${APPLE_API_KEY_ID}" \ -authenticationKeyIssuerID "${APPLE_API_ISSUER}" - name: Upload TestFlight IPA artifact uses: actions/upload-artifact@v4 with: name: tutti-mobile-ios-testflight-${{ github.sha }} path: | ${{ runner.temp }}/tutti-ios-artifact/tutti-mobile-testflight.ipa ${{ runner.temp }}/tutti-ios-artifact/SHA256SUMS if-no-files-found: error retention-days: 14