From c4d94ddd2e1e37d5beb343aca7da46a86211f12f Mon Sep 17 00:00:00 2001 From: Siddarth Kumar Date: Wed, 3 Dec 2025 22:13:10 +0530 Subject: [PATCH] ci: create and sign separate bundle ID for PRs - points to `fix-ios-signing-with-fastlane` branch of `status-jenkins-lib` which provides development certs for PR builds and distribution certs for release builds. - adds nix flake to provide ruby dependencies for `fastlane` - adds config for new build identifier for PRs: `app.status.mobile.pr` - swaps testflight script with fastlane action - adds job to build iOS in release --- .gitignore | 10 ++ ci/Jenkinsfile.combined | 7 +- ci/Jenkinsfile.ios | 21 ++- ci/README.md | 63 ++++++++ mobile/fastlane/Appfile | 7 + mobile/fastlane/Fastfile | 172 ++++++++++++++++++++++ mobile/fastlane/Gemfile | 7 + mobile/fastlane/Gemfile.lock | 232 +++++++++++++++++++++++++++++ mobile/fastlane/Matchfile | 19 +++ mobile/fastlane/README.md | 40 +++++ mobile/fastlane/flake.lock | 61 ++++++++ mobile/fastlane/flake.nix | 59 ++++++++ mobile/scripts/buildApp.sh | 35 +++-- mobile/scripts/ios/sign.sh | 122 --------------- mobile/wrapperApp/Status.pro | 17 ++- scripts/diawi-upload.mjs | 122 +++++++++++++++ scripts/extract-bundle-version.sh | 17 --- scripts/testflight-changelog.mjs | 236 ------------------------------ scripts/upload-testflight.sh | 41 ------ 19 files changed, 851 insertions(+), 437 deletions(-) create mode 100644 mobile/fastlane/Appfile create mode 100644 mobile/fastlane/Fastfile create mode 100644 mobile/fastlane/Gemfile create mode 100644 mobile/fastlane/Gemfile.lock create mode 100644 mobile/fastlane/Matchfile create mode 100644 mobile/fastlane/README.md create mode 100644 mobile/fastlane/flake.lock create mode 100644 mobile/fastlane/flake.nix delete mode 100755 mobile/scripts/ios/sign.sh create mode 100644 scripts/diawi-upload.mjs delete mode 100755 scripts/extract-bundle-version.sh delete mode 100755 scripts/testflight-changelog.mjs delete mode 100755 scripts/upload-testflight.sh diff --git a/.gitignore b/.gitignore index 0a5c65edaf7..a6e3b20db61 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,13 @@ mobile/bin/ mobile/lib/ mobile/build/ scripts/node_modules/ + +# fastlane +mobile/fastlane/.gems/ +mobile/fastlane/vendor/bundle/ +mobile/fastlane/.bundle/ +mobile/fastlane/report.xml +mobile/fastlane/Preview.html +mobile/fastlane/screenshots/ +mobile/fastlane/test_output/ +mobile/fastlane/result diff --git a/ci/Jenkinsfile.combined b/ci/Jenkinsfile.combined index e9aeb45f8d2..4a270aa30f2 100644 --- a/ci/Jenkinsfile.combined +++ b/ci/Jenkinsfile.combined @@ -1,6 +1,6 @@ #!/usr/bin/env groovy -library 'status-jenkins-lib@v1.9.31' +library 'status-jenkins-lib@fix-ios-signing-with-fastlane' /* Object to store public URLs for description. */ urls = [:] @@ -113,6 +113,11 @@ pipeline { 'MacOS/aarch64', jenkins.Build('status-app/systems/macos/aarch64/package') ) } } } + stage('iOS/aarch64') { steps { script { + ios_aarch64 = getArtifacts( + 'iOS/aarch64', jenkins.Build('status-app/systems/ios/arm64/package') + ) + } } } } } stage('Publish') { diff --git a/ci/Jenkinsfile.ios b/ci/Jenkinsfile.ios index c3a0c7dbc77..974b210c06d 100644 --- a/ci/Jenkinsfile.ios +++ b/ci/Jenkinsfile.ios @@ -1,5 +1,5 @@ #!/usr/bin/env groovy -library 'status-jenkins-lib@v1.9.31' +library 'status-jenkins-lib@fix-ios-signing-with-fastlane' /* Options section can't access functions in objects. */ def isPRBuild = utils.isPRBuild() @@ -73,10 +73,11 @@ pipeline { /* iOS build configuration */ IPHONE_SDK = "iphoneos" ARCH = "x86_64" - /* iOS app paths */ + /* iOS app paths - PR builds use StatusPR, release builds use Status */ + STATUS_IOS_APP_NAME = "${utils.isReleaseBuild() ? 'Status' : 'StatusPR'}" STATUS_IOS_APP_ARTIFACT = "pkg/${utils.pkgFilename(ext: 'ipa', arch: getArch(), version: env.VERSION, type: env.APP_TYPE)}" - STATUS_IOS_APP = "${WORKSPACE}/mobile/bin/ios/qt6/Status.app" - STATUS_IOS_IPA = "${WORKSPACE}/mobile/bin/ios/qt6/Status.ipa" + STATUS_IOS_APP = "${WORKSPACE}/mobile/bin/ios/qt6/${env.STATUS_IOS_APP_NAME}.app" + STATUS_IOS_IPA = "${WORKSPACE}/mobile/bin/ios/qt6/${env.STATUS_IOS_APP_NAME}.ipa" TESTFLIGHT_POLL_TIMEOUT = "${params.TESTFLIGHT_POLL_TIMEOUT}" TESTFLIGHT_POLL_INTERVAL = "${params.TESTFLIGHT_POLL_INTERVAL}" /* nwaku source directory */ @@ -124,6 +125,7 @@ pipeline { stage('Parallel Upload') { parallel { stage('Upload to TestFlight') { + when { expression { utils.isReleaseBuild() } } steps { script { def changelog = sh(script: './scripts/generate-changelog.sh', returnStdout: true).trim() @@ -137,11 +139,20 @@ pipeline { } } } + stage('Upload to Diawi') { + when { expression { !utils.isReleaseBuild() } } + steps { + script { + def comment = "status-desktop PR build ${env.VERSION} (${env.GIT_COMMIT?.take(8) ?: 'unknown'})" + env.DIAWI_URL = app.uploadToDiawi(env.STATUS_IOS_APP_ARTIFACT, comment) + jenkins.setBuildDesc(IPA: env.DIAWI_URL) + } + } + } stage('Upload to S3') { steps { script { env.PKG_URL = s5cmd.upload(env.STATUS_IOS_APP_ARTIFACT) - jenkins.setBuildDesc(IPA: env.PKG_URL) } } } diff --git a/ci/README.md b/ci/README.md index 65607bf0c40..d8fe6b22285 100644 --- a/ci/README.md +++ b/ci/README.md @@ -26,3 +26,66 @@ It also expects the presence of the following credentials: * `macos-keychain-file` - Keychain file with the MacOS signing certificate. You can read about how to create such a keychain [here](https://github.com/status-im/infra-docs/blob/master/articles/macos_signing_keychain.md). + +## iOS + +iOS builds use **fastlane** with **match** for code signing management. This provides: +- Automatic certificate and profile management +- Separate signing for PR vs release builds + +### Bundle Identifiers + +| Build Type | Bundle ID | Fastlane Lane | +|------------|------------------------|---------------| +| PR builds | `app.status.mobile.pr` | `pr` | +| Release | `app.status.mobile` | `release` | + +### Certificate Types + +| Build Type | Certificate Type | Match Type | Purpose | +|------------|--------------------|-------------|-------------------------------| +| PR builds | Apple Distribution | `adhoc` | Testing on registered devices | +| Release | Apple Distribution | `appstore` | App Store / TestFlight | + +### Fastlane Files + +The `fastlane` configuration is located in `mobile/fastlane/`: + +| File | Purpose | +|-------------|----------------------------------------------| +| `Fastfile` | Defines signing lanes (`pr`, `release`) | +| `Matchfile` | Configures match for certificate management | +| `Appfile` | App identifiers and team configuration | +| `Gemfile` | Ruby dependencies | + + +### Local Development + +To run `fastlane` locally for testing: + +```bash +cd mobile/fastlane +nix --extra-experimental-features 'nix-command flakes' develop +bundle install + +# Run a specific lane +bundle exec fastlane ios pr +bundle exec fastlane ios release +``` + +### Revoking/Rotating Certificates + +If a certificate is compromised or revoked: + +```bash +cd mobile/fastlane + +# Nuke existing certificates (warning!! watch what you nuke) +bundle exec fastlane match nuke development +bundle exec fastlane match nuke distribution + +# Regenerate +bundle exec fastlane match development --app_identifier "app.status.mobile.pr" +bundle exec fastlane match appstore --app_identifier "app.status.mobile" +``` + diff --git a/mobile/fastlane/Appfile b/mobile/fastlane/Appfile new file mode 100644 index 00000000000..a6e7faa0f15 --- /dev/null +++ b/mobile/fastlane/Appfile @@ -0,0 +1,7 @@ +# App identifiers for Status App iOS builds +app_identifier("app.status.mobile") +team_id(ENV["FASTLANE_TEAM_ID"]) + +for_lane :pr do + app_identifier("app.status.mobile.pr") +end diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile new file mode 100644 index 00000000000..097d150251c --- /dev/null +++ b/mobile/fastlane/Fastfile @@ -0,0 +1,172 @@ +# This file defines the signing and packaging lanes for iOS +# Building is done separately via make targets + +default_platform(:ios) + +# Build configuration +APP_NAME_RELEASE = "Status.app" +APP_NAME_PR = "StatusPR.app" +DISPLAY_NAME_RELEASE = "Status" +DISPLAY_NAME_PR = "Status PR" +PROJECT_DIR = File.expand_path("../", __dir__) +BUILD_DIR = File.join(PROJECT_DIR, "bin", "ios", "qt6") + +platform :ios do + before_all do + UI.message("Project directory: #{PROJECT_DIR}") + UI.message("Build directory: #{BUILD_DIR}") + end + + after_all do + # Clean up CI keychain after signing + if is_ci + delete_keychain(name: keychain_name) if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db")) + end + end + + error do + # Clean up CI keychain on failure too + if is_ci + delete_keychain(name: keychain_name) rescue nil + end + end + + # ============================================ + # PR Builds - Sign and package for ad-hoc distribution + # ============================================ + desc "Sign and package iOS app for PRs" + lane :pr do + setup_ci_keychain + + run_match(type: "adhoc") + + resign_and_package( + app_name: APP_NAME_PR, + display_name: DISPLAY_NAME_PR, + profile_type: "adhoc" + ) + end + + # ============================================ + # Release Builds - Sign and package for App Store + # ============================================ + desc "Sign and package iOS app for release" + lane :release do + setup_ci_keychain + + run_match(type: "appstore") + + resign_and_package( + app_name: APP_NAME_RELEASE, + display_name: DISPLAY_NAME_RELEASE, + profile_type: "appstore" + ) + + if ENV["UPLOAD_TO_TESTFLIGHT"] == "true" + upload_to_testflight( + ipa: File.join(BUILD_DIR, "Status.ipa"), + skip_waiting_for_build_processing: true, + changelog: ENV["CHANGELOG"] || "New build from CI" + ) + end + end + + # ============================================ + # Helper Methods + # ============================================ + + private_lane :setup_ci_keychain do + if is_ci + create_keychain( + name: keychain_name, + password: keychain_password, + default_keychain: true, + unlock: true, + timeout: 3600, + lock_when_sleeps: false + ) + end + end + + private_lane :run_match do |options| + match_params = { + type: options[:type], + readonly: false, + # Auto-regenerate profiles when new devices are registered (for dev and adhoc) + force_for_new_devices: options[:type] == "adhoc" + } + + # Only specify keychain params in CI where we create a custom keychain + if is_ci + match_params[:keychain_name] = keychain_name + match_params[:keychain_password] = keychain_password + end + + match(match_params) + end + + private_lane :resign_and_package do |options| + app_identifier = CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) + app_name = options[:app_name] || "Status.app" + display_name = options[:display_name] || "Status" + profile_type = options[:profile_type] + + app_path = File.join(BUILD_DIR, app_name) + ipa_name = app_name.sub(".app", ".ipa") + ipa_path = File.join(BUILD_DIR, ipa_name) + + unless File.exist?(app_path) + UI.user_error!("#{app_name} not found at #{app_path}") + end + + # Get signing identity and provisioning profile from match + signing_identity = ENV["sigh_#{app_identifier}_#{profile_type}_certificate-name"] + provisioning_profile = ENV["sigh_#{app_identifier}_#{profile_type}_profile-path"] + + UI.message("Signing identity: #{signing_identity}") + UI.message("Provisioning profile: #{provisioning_profile}") + + unless provisioning_profile && File.exist?(provisioning_profile) + UI.user_error!("Provisioning profile not found!") + end + + unless signing_identity + UI.user_error!("Signing identity not found!") + end + + UI.message("Creating unsigned IPA...") + FileUtils.rm_f(ipa_path) + + Dir.mktmpdir do |tmpdir| + payload_dir = File.join(tmpdir, "Payload") + FileUtils.mkdir_p(payload_dir) + FileUtils.cp_r(app_path, payload_dir) + + Dir.chdir(tmpdir) do + sh("zip -r '#{ipa_path}' Payload") + end + end + + # This handles frameworks, entitlements patching, and bundle ID updates + UI.message("Resigning IPA with fastlane resign action...") + resign( + ipa: ipa_path, + signing_identity: signing_identity, + provisioning_profile: { + app_identifier => provisioning_profile + }, + bundle_id: app_identifier, + display_name: display_name + ) + + UI.success("Signed and packaged: #{ipa_path}") + end + + def keychain_name + "status_ci_#{ENV['BUILD_NUMBER'] || 'local'}.keychain" + end + + def keychain_password + ENV["MATCH_PASSWORD"] || "fastlane_ci_password" + end +end diff --git a/mobile/fastlane/Gemfile b/mobile/fastlane/Gemfile new file mode 100644 index 00000000000..dc3b05b7682 --- /dev/null +++ b/mobile/fastlane/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +# Core dependencies +gem "fastlane", "~> 2.225" + +plugins_path = File.join(File.dirname(__FILE__), 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/mobile/fastlane/Gemfile.lock b/mobile/fastlane/Gemfile.lock new file mode 100644 index 00000000000..34c8b914fdb --- /dev/null +++ b/mobile/fastlane/Gemfile.lock @@ -0,0 +1,232 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.9) + abbrev (0.1.2) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1190.0) + aws-sdk-core (3.239.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.206.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + bigdecimal (3.3.1) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.1) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.229.1) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.17.1) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.18.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.0) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.3.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-24 + +DEPENDENCIES + fastlane (~> 2.225) + +BUNDLED WITH + 2.3.27 diff --git a/mobile/fastlane/Matchfile b/mobile/fastlane/Matchfile new file mode 100644 index 00000000000..31b03810bcd --- /dev/null +++ b/mobile/fastlane/Matchfile @@ -0,0 +1,19 @@ +# Configuration for fastlane match +# match manages iOS code signing certificates and provisioning profiles + +# Git repository for storing encrypted certificates and profiles +git_url("git@github.com:status-im/status-app-certificates.git") + +storage_mode("git") + +app_identifier([ + "app.status.mobile", + "app.status.mobile.pr" +]) + +# App Store Connect API Key +api_key_path(ENV["ASC_API_KEY_JSON"]) if ENV["ASC_API_KEY_JSON"] + +# Keychain configuration +keychain_name(ENV["KEYCHAIN_NAME"]) if ENV["KEYCHAIN_NAME"] +keychain_password(ENV["KEYCHAIN_PASSWORD"]) if ENV["KEYCHAIN_PASSWORD"] diff --git a/mobile/fastlane/README.md b/mobile/fastlane/README.md new file mode 100644 index 00000000000..e59d7b4e71c --- /dev/null +++ b/mobile/fastlane/README.md @@ -0,0 +1,40 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios pr + +```sh +[bundle exec] fastlane ios pr +``` + +Sign and package iOS app for PRs + +### ios release + +```sh +[bundle exec] fastlane ios release +``` + +Sign and package iOS app for release + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/mobile/fastlane/flake.lock b/mobile/fastlane/flake.lock new file mode 100644 index 00000000000..99a184c128a --- /dev/null +++ b/mobile/fastlane/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1765687488, + "narHash": "sha256-7YAJ6xgBAQ/Nr+7MI13Tui1ULflgAdKh63m1tfYV7+M=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d02bcc33948ca19b0aaa0213fe987ceec1f4ebe1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/mobile/fastlane/flake.nix b/mobile/fastlane/flake.nix new file mode 100644 index 00000000000..4c021792919 --- /dev/null +++ b/mobile/fastlane/flake.nix @@ -0,0 +1,59 @@ +{ + description = "Fastlane environment for iOS signing"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + # Pin Ruby 3.1 for fastlane + ruby = pkgs.ruby_3_1; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + ruby + pkgs.git + pkgs.openssh + pkgs.openssl + pkgs.cacert + pkgs.curl + pkgs.pkg-config + pkgs.libyaml + ]; + + shellHook = '' + export GEM_HOME="$PWD/.gems" + export GEM_PATH="$GEM_HOME" + export PATH="$GEM_HOME/bin:$PATH" + export LANG="en_US.UTF-8" + + export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + export NIX_SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + export CURL_CA_BUNDLE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" + + # fastlane resign needs xcode tools in shell + XCODE_WRAPPER_DIR=$(mktemp -d) + for tool in xcrun codesign security xcodebuild plutil; do + ln -sf /usr/bin/$tool "$XCODE_WRAPPER_DIR/$tool" 2>/dev/null || true + done + export PATH="$XCODE_WRAPPER_DIR:$PATH" + + unset BUNDLE_PATH + unset BUNDLE_GEMFILE + + echo "Ruby $(ruby --version)" + echo "Bundler $(bundle --version)" + echo "" + echo "Installing fastlane dependencies..." + bundle install + ''; + }; + } + ); +} diff --git a/mobile/scripts/buildApp.sh b/mobile/scripts/buildApp.sh index 3ec8de95c81..2031c932288 100755 --- a/mobile/scripts/buildApp.sh +++ b/mobile/scripts/buildApp.sh @@ -10,12 +10,18 @@ BIN_DIR=${BIN_DIR:-"$CWD/../bin/ios"} BUILD_DIR=${BUILD_DIR:-"$CWD/../build"} ANDROID_ABI=${ANDROID_ABI:-"arm64-v8a"} BUILD_TYPE=${BUILD_TYPE:-"apk"} -SIGN_IOS=${SIGN_IOS:-"false"} + +# BUILD_VARIANT controls bundle ID: "pr" = app.status.mobile.pr, "release" = app.status.mobile +BUILD_VARIANT=${BUILD_VARIANT:-"release"} +export BUILD_VARIANT QMAKE_BIN="${QMAKE:-qmake}" QMAKE_CONFIG="CONFIG+=device CONFIG+=release" +PRO_FILE="$CWD/../wrapperApp/Status.pro" + echo "Building wrapperApp for ${OS}, ${ANDROID_ABI}" +echo "Using project file: $PRO_FILE" mkdir -p "${BUILD_DIR}" cd "${BUILD_DIR}" @@ -26,9 +32,9 @@ DESKTOP_VERSION=$(eval cd "$STATUS_DESKTOP" && git describe --tags --dirty="-dir TIMESTAMP=$(($(date +%s) * 1000 / 60000)) if [[ -n "${CHANGE_ID:-}" ]]; then - BUILD_VERSION="${CHANGE_ID}.${TIMESTAMP}" + BUILD_VERSION="${CHANGE_ID}.${TIMESTAMP}" else - BUILD_VERSION="${TIMESTAMP}" + BUILD_VERSION="${TIMESTAMP}" fi echo "Using version: $DESKTOP_VERSION; build version: $BUILD_VERSION" @@ -42,7 +48,7 @@ if [[ "${OS}" == "android" ]]; then echo "Building for Android 35" ANDROID_PLATFORM=android-35 - "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" -after + "$QMAKE_BIN" "$PRO_FILE" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" -after # Build the app make -j"$(nproc)" apk_install_target @@ -122,21 +128,24 @@ if [[ "${OS}" == "android" ]]; then fi fi else - "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" -after + "$QMAKE_BIN" "$PRO_FILE" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" -after + + if [[ "$BUILD_VARIANT" == "pr" ]]; then + TARGET_NAME="StatusPR" + else + TARGET_NAME="Status" + fi # Compile resources xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify # Compile the app - xcodebuild -configuration Release -target Status install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify + xcodebuild -configuration Release -target "$TARGET_NAME" install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify - if [[ ! -e "$BIN_DIR/Status.app/Info.plist" ]]; then - echo "Build failed" + if [[ ! -e "${BIN_DIR}/${TARGET_NAME}.app/Info.plist" ]]; then + echo "Build failed -> ${BIN_DIR}/${TARGET_NAME}.app not found" exit 1 fi - if [[ "$SIGN_IOS" == "true" ]]; then - "$CWD/ios/sign.sh" - fi - - echo "Build succeeded" + # Note: iOS signing is handled by fastlane + echo "Build succeeded! unsigned app ready for fastlane signing" fi diff --git a/mobile/scripts/ios/sign.sh b/mobile/scripts/ios/sign.sh deleted file mode 100755 index ebc9c22d25f..00000000000 --- a/mobile/scripts/ios/sign.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CWD=$(realpath "$(dirname "$0")") -BIN_DIR=${BIN_DIR:-"$CWD/../../bin/ios"} - -if [[ ! -e "$BIN_DIR/Status.app/Info.plist" ]]; then - echo "Error: Status.app not found at $BIN_DIR/Status.app" - exit 1 -fi - -function required_var() { - if [[ -z "${!1}" ]]; then - echo -e "ERROR: No required env variable: ${1}" 1>&2 - exit 1 - fi -} - -required_var IOS_CERT_PATH -required_var IOS_CERT_PASSWORD -required_var IOS_PROVISIONING_PROFILE - -echo "Signing iOS app at $BIN_DIR/Status.app..." - -KEYCHAIN_NAME="build-$$.keychain" -KEYCHAIN_PASSWORD=$(openssl rand -base64 16) - -cleanup_keychain() { - echo "Cleaning up keychain..." - security default-keychain -s login.keychain 2>/dev/null || true - security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true -} - -trap cleanup_keychain EXIT - -security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true - -security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" -security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" -security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME" -security list-keychains -s "$KEYCHAIN_NAME" login.keychain -security default-keychain -s "$KEYCHAIN_NAME" - -echo "Importing Apple WWDR G3 certificate..." -WWDR_TEMP_DIR=$(mktemp -d) -curl -sS -o "$WWDR_TEMP_DIR/AppleWWDRCAG3.cer" https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -security import "$WWDR_TEMP_DIR/AppleWWDRCAG3.cer" -k "$KEYCHAIN_NAME" -T /usr/bin/codesign -rm -rf "$WWDR_TEMP_DIR" -echo "Apple WWDR G3 certificate imported" - -security import "$IOS_CERT_PATH" -k "$KEYCHAIN_NAME" -P "$IOS_CERT_PASSWORD" -T /usr/bin/codesign -security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" - -PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" -mkdir -p "$PROFILE_DIR" - -PROFILE_UUID=$(security cms -D -i "$IOS_PROVISIONING_PROFILE" 2>/dev/null | grep -A1 "UUID" | grep "" | sed 's/.*\(.*\)<\/string>.*/\1/') - -rm -f "$PROFILE_DIR/$PROFILE_UUID.mobileprovision" - -cp "$IOS_PROVISIONING_PROFILE" "$PROFILE_DIR/$PROFILE_UUID.mobileprovision" - -echo "Installed provisioning profile: $PROFILE_UUID" - -echo "Embedding provisioning profile into app..." -cp "$IOS_PROVISIONING_PROFILE" "$BIN_DIR/Status.app/embedded.mobileprovision" - -echo "Searching for signing identity in keychain..." -security find-identity -v -p codesigning "$KEYCHAIN_NAME" - -SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_NAME" | grep -E "iPhone Distribution|Apple Distribution" | head -1 | awk '{print $2}') - -if [[ -z "$SIGNING_IDENTITY" ]]; then - echo "ERROR: No Distribution certificate found in keychain!" - echo "Available identities:" - security find-identity -v -p codesigning "$KEYCHAIN_NAME" - exit 1 -fi - -echo "Signing with identity: $SIGNING_IDENTITY" - -echo "Extracting entitlements from provisioning profile..." -ENTITLEMENTS_PLIST=$(mktemp -t entitlements).plist - -security cms -D -i "$IOS_PROVISIONING_PROFILE" | \ - plutil -extract Entitlements xml1 - -o "$ENTITLEMENTS_PLIST" - -echo "Entitlements extracted to: $ENTITLEMENTS_PLIST" -cat "$ENTITLEMENTS_PLIST" - -echo "Signing embedded frameworks..." -if [ -d "$BIN_DIR/Status.app/Frameworks" ]; then - find "$BIN_DIR/Status.app/Frameworks" -name "*.framework" -type d | while read -r framework; do - echo "Signing framework: $(basename "$framework")" - codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$framework" - done -fi - -echo "Signing main app bundle..." -codesign --force --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS_PLIST" --timestamp "$BIN_DIR/Status.app" - -rm -f "$ENTITLEMENTS_PLIST" - -echo "Verifying signature..." -codesign --verify --verbose=4 "$BIN_DIR/Status.app" - -echo "Signature details:" -codesign -d --entitlements :- "$BIN_DIR/Status.app" - -echo "iOS app signed successfully" - -echo "Creating IPA file..." -IPA_DIR=$(mktemp -d) -mkdir -p "$IPA_DIR/Payload" -cp -R "$BIN_DIR/Status.app" "$IPA_DIR/Payload/" - -cd "$IPA_DIR" -zip -r "$BIN_DIR/Status.ipa" Payload -cd - - -rm -rf "$IPA_DIR" -echo "IPA created at $BIN_DIR/Status.ipa" diff --git a/mobile/wrapperApp/Status.pro b/mobile/wrapperApp/Status.pro index 4c974aa5397..ef9bf115622 100644 --- a/mobile/wrapperApp/Status.pro +++ b/mobile/wrapperApp/Status.pro @@ -47,11 +47,24 @@ ios { QMAKE_INFO_PLIST = $$PWD/../ios/Info.plist QMAKE_IOS_DEPLOYMENT_TARGET=16.0 - QMAKE_TARGET_BUNDLE_PREFIX = app.status - QMAKE_BUNDLE = mobile QMAKE_ASSET_CATALOGS += $$PWD/../ios/Images.xcassets QMAKE_IOS_LAUNCH_SCREEN = $$PWD/../ios/launch-image-universal.storyboard + # Bundle identifier configuration based on BUILD_VARIANT environment variable + # - PR builds (BUILD_VARIANT=pr): app.status.mobile.pr + # - Release/Local dev (BUILD_VARIANT unset or "release"): app.status.mobile + BUILD_VARIANT_ENV = $$(BUILD_VARIANT) + equals(BUILD_VARIANT_ENV, "pr") { + TARGET = StatusPR + QMAKE_TARGET_BUNDLE_PREFIX = app.status.mobile + QMAKE_BUNDLE = pr + } else { + # Default for local development and release builds + TARGET = Status + QMAKE_TARGET_BUNDLE_PREFIX = app.status + QMAKE_BUNDLE = mobile + } + LIBS += -L$$PWD/../lib/$$LIB_PREFIX -lnim_status_client -lDOtherSideStatic -lstatusq -lstatus -lsds -lssl_3 -lcrypto_3 -lqzxing -lresolv -lqrcodegen # --- iOS frameworks required by keychain_apple.mm --- diff --git a/scripts/diawi-upload.mjs b/scripts/diawi-upload.mjs new file mode 100644 index 00000000000..9c5ba1e1619 --- /dev/null +++ b/scripts/diawi-upload.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import https from 'node:https' +import { basename } from 'node:path' +import { promisify } from 'node:util' +import { createReadStream } from 'node:fs' + +import log from 'npmlog' +import FormData from 'form-data' + +const UPLOAD_URL = 'https://upload.diawi.com/' +const STATUS_URL = 'https://upload.diawi.com/status' +const DIAWI_TOKEN = process.env.DIAWI_TOKEN +const LOG_LEVEL = process.env.VERBOSE ? 'verbose' : 'info' +const POLL_MAX_COUNT = process.env.POLL_MAX_COUNT ? process.env.POLL_MAX_COUNT : 120 +const POLL_INTERVAL_MS = process.env.POLL_INTERVAL_MS ? process.env.POLL_INTERVAL_MS : 500 + +const sleep = (ms) => { + return new Promise((resolve, reject) => { + setTimeout(resolve, (ms)) + }) +} + +const getRequest = async (url) => { + return new Promise((resolve, reject) => { + let data = [] + https.get(url, res => { + res.on('error', err => reject(err)) + res.on('data', chunk => { data.push(chunk) }) + res.on('end', () => { + let payload = Buffer.concat(data).toString() + resolve({ + code: res.statusCode, + message: res.statusMessage, + payload: payload, + }) + }) + }) + }) +} + +const uploadIpa = async (ipaPath, comment, token) => { + let form = new FormData() + form.append('token', token) + form.append('file', createReadStream(ipaPath)) + form.append('comment', comment || basename(ipaPath)) + + const formSubmitPromise = promisify(form.submit.bind(form)) + + const res = await formSubmitPromise(UPLOAD_URL) + if (res.statusCode != 200) { + log.error('uploadIpa', 'Upload failed: %d %s', res.statusCode, res.statusMessage) + process.exit(1) + } + + return new Promise((resolve) => { + const jobId = res.on('data', async (data) => { + resolve(JSON.parse(data)['job']) + }) + }) +} + +const checkStatus = async (jobId, token) => { + let params = new URLSearchParams({ + token: token, job: jobId, + }) + let rval = await getRequest(`${STATUS_URL}?${params.toString()}`) + if (rval.code != 200) { + log.error('checkStatus', 'Check query failed: %d %s', rval.code, rval.message) + process.exit(1) + } + return JSON.parse(rval.payload) +} + +const pollStatus = async (jobId, token) => { + let interval = POLL_INTERVAL_MS + for (let i = 0; i <= POLL_MAX_COUNT; i++) { + let json = await checkStatus(jobId, token) + switch (json.status) { + case 2000: + return json + case 2001: + log.verbose('pollStatus', 'Waiting: %s', json.message) + break /* Nothing, just poll again. */ + case 4000000: + log.warning('pollStatus', 'Doubling polling interval: %s', json.message) + interval *= 2 + break + default: + log.error('pollStatus', `Error in status response: ${json.message}`) + process.exit(1) + } + await sleep(interval) + } + log.error('pollStatus', 'Failed to poll status after %d retries.', POLL_MAX_COUNT) + process.exit(1) +} + +const main = async () => { + const targetFile = process.argv[2] + const comment = process.argv[3] + log.level = LOG_LEVEL + + if (DIAWI_TOKEN === undefined) { + log.error('main', 'No DIAWI_TOKEN env var provided!') + process.exit(1) + } + if (targetFile === undefined) { + log.error('main', 'No file path provided!') + process.exit(1) + } + + log.info('main', 'Uploading: %s', targetFile) + let jobId = await uploadIpa(targetFile, comment, DIAWI_TOKEN) + + log.info('main', 'Polling upload job status: %s', jobId) + let uploadMeta = await pollStatus(jobId, DIAWI_TOKEN) + + console.log(uploadMeta) +} + +main() diff --git a/scripts/extract-bundle-version.sh b/scripts/extract-bundle-version.sh deleted file mode 100755 index 3c5e9faf1aa..00000000000 --- a/scripts/extract-bundle-version.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -lt 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -IPA_PATH="$1" - -if [[ ! -f "$IPA_PATH" ]]; then - echo "Error: IPA file not found at $IPA_PATH" >&2 - exit 1 -fi - -unzip -p "$IPA_PATH" 'Payload/*.app/Info.plist' | \ - plutil -extract CFBundleVersion raw -o - - diff --git a/scripts/testflight-changelog.mjs b/scripts/testflight-changelog.mjs deleted file mode 100755 index 108749dc284..00000000000 --- a/scripts/testflight-changelog.mjs +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync } from 'fs' -import https from 'https' -import jwt from 'jsonwebtoken' - -const APP_BUNDLE_ID = 'app.status.mobile' - -const ASC_KEY_ID = process.env.ASC_KEY_ID -const ASC_ISSUER_ID = process.env.ASC_ISSUER_ID -const ASC_KEY_FILE = process.env.ASC_KEY_FILE -const BUILD_VERSION = process.env.BUILD_VERSION -const CHANGELOG = process.env.CHANGELOG -const POLL_TIMEOUT_MINUTES = parseInt(process.env.POLL_TIMEOUT_MINUTES || '30', 10) -const POLL_INTERVAL_SECONDS = parseInt(process.env.POLL_INTERVAL_SECONDS || '30', 10) - -if (!ASC_KEY_ID || !ASC_ISSUER_ID || !ASC_KEY_FILE) { - console.error('ERROR: Missing required environment variables (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_FILE)') - process.exit(1) -} - -if (!BUILD_VERSION || !CHANGELOG) { - console.error('ERROR: Missing BUILD_VERSION or CHANGELOG environment variable') - process.exit(1) -} - -function generateJWT() { - const privateKey = readFileSync(ASC_KEY_FILE, 'utf8') - - // Apple requires tokens to expire within 20 minutes for security - // https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests - const payload = { - iss: ASC_ISSUER_ID, - iat: Math.floor(Date.now() / 1000), // Issues At - exp: Math.floor(Date.now() / 1000) + 1200, // Expires in 20 minutes - aud: 'appstoreconnect-v1' - } - - const token = jwt.sign(payload, privateKey, { - algorithm: 'ES256', - header: { - alg: 'ES256', - kid: ASC_KEY_ID, - typ: 'JWT' - } - }) - - return token -} - -function apiRequest(path, options = {}) { - return new Promise((resolve, reject) => { - const jwt = generateJWT() - - const reqOptions = { - hostname: 'api.appstoreconnect.apple.com', - path: path, - method: options.method || 'GET', - headers: { - 'Authorization': `Bearer ${jwt}`, - 'Content-Type': 'application/json', - ...options.headers - } - } - - const req = https.request(reqOptions, (res) => { - let data = '' - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(JSON.parse(data)) - } else { - reject(new Error(`API request failed: ${res.statusCode} - ${data}`)) - } - }) - }) - - req.on('error', reject) - - if (options.body) { - req.write(JSON.stringify(options.body)) - } - - req.end() - }) -} - -async function findApp() { - console.log(`Finding app with bundle ID: ${APP_BUNDLE_ID}`) - // https://developer.apple.com/documentation/appstoreconnectapi/list_apps - const response = await apiRequest(`/v1/apps?filter[bundleId]=${APP_BUNDLE_ID}`) - - if (!response.data || response.data.length === 0) { - throw new Error(`App not found with bundle ID: ${APP_BUNDLE_ID}`) - } - - return response.data[0].id -} - -async function findBuild(appId) { - // https://developer.apple.com/documentation/appstoreconnectapi/list_builds - const response = await apiRequest(`/v1/builds?filter[app]=${appId}&filter[version]=${BUILD_VERSION}&sort=-uploadedDate&limit=1`) - - if (!response.data || response.data.length === 0) { - return null - } - - return response.data[0].id -} - -async function pollForBuild(appId, timeoutMinutes = 30, pollIntervalSeconds = 30) { - const timeoutMs = timeoutMinutes * 60 * 1000 - const pollIntervalMs = pollIntervalSeconds * 1000 - const startTime = Date.now() - - console.log(`Polling for build version ${BUILD_VERSION}...`) - console.log(`Timeout: ${timeoutMinutes} minutes, Poll interval: ${pollIntervalSeconds} seconds`) - - let attempt = 0 - while (Date.now() - startTime < timeoutMs) { - attempt++ - const elapsedMinutes = ((Date.now() - startTime) / 1000 / 60).toFixed(1) - - console.log(`Attempt ${attempt} (${elapsedMinutes}/${timeoutMinutes} min): Checking for build...`) - - const buildId = await findBuild(appId) - - if (buildId) { - console.log(`Build found: ${buildId}`) - return buildId - } - - const remainingMs = timeoutMs - (Date.now() - startTime) - if (remainingMs < pollIntervalMs) { - break - } - - console.log(`Build not ready yet, waiting ${pollIntervalSeconds} seconds...`) - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) - } - - throw new Error(`Timeout: Build version ${BUILD_VERSION} not found after ${timeoutMinutes} minutes`) -} - -async function createBetaBuildLocalization(buildId, changelog) { - console.log(`Setting changelog for build: ${buildId}`) - - const body = { - data: { - type: 'betaBuildLocalizations', - attributes: { - locale: 'en-US', - whatsNew: changelog - }, - relationships: { - build: { - data: { - type: 'builds', - id: buildId - } - } - } - } - } - - try { - // https://developer.apple.com/documentation/appstoreconnectapi/create_a_beta_build_localization - const response = await apiRequest('/v1/betaBuildLocalizations', { - method: 'POST', - body: body - }) - console.log('Changelog set successfully') - return response - } catch (error) { - if (error.message.includes('409')) { - console.log('Localization already exists, updating...') - return await updateBetaBuildLocalization(buildId, changelog) - } - throw error - } -} - -async function updateBetaBuildLocalization(buildId, changelog) { - // https://developer.apple.com/documentation/appstoreconnectapi/list_all_beta_build_localizations_for_a_build - const response = await apiRequest(`/v1/builds/${buildId}/betaBuildLocalizations`) - - if (!response.data || response.data.length === 0) { - throw new Error('No existing localization found to update') - } - - const localizationId = response.data[0].id - - const body = { - data: { - type: 'betaBuildLocalizations', - id: localizationId, - attributes: { - whatsNew: changelog - } - } - } - - // https://developer.apple.com/documentation/appstoreconnectapi/modify_a_beta_build_localization - await apiRequest(`/v1/betaBuildLocalizations/${localizationId}`, { - method: 'PATCH', - body: body - }) - - console.log('Changelog updated successfully') -} - -async function main() { - try { - console.log('Setting TestFlight changelog...') - console.log(`Changelog: ${CHANGELOG}`) - - const appId = await findApp() - console.log(`App ID: ${appId}`) - - const buildId = await pollForBuild(appId, POLL_TIMEOUT_MINUTES, POLL_INTERVAL_SECONDS) - console.log(`Build ID: ${buildId}`) - - await createBetaBuildLocalization(buildId, CHANGELOG) - - console.log('TestFlight changelog set successfully') - } catch (error) { - console.error('Failed to set TestFlight changelog:', error.message) - process.exit(1) - } -} - -main() diff --git a/scripts/upload-testflight.sh b/scripts/upload-testflight.sh deleted file mode 100755 index ce049bd32c0..00000000000 --- a/scripts/upload-testflight.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -lt 1 ]]; then - echo "Usage: $0 " - exit 1 -fi - -IPA_PATH="$1" - -if [[ ! -f "$IPA_PATH" ]]; then - echo "Error: IPA file not found at $IPA_PATH" - exit 1 -fi - -if [[ -z "${ASC_KEY_ID:-}" || -z "${ASC_ISSUER_ID:-}" || -z "${ASC_KEY_FILE:-}" ]]; then - echo "Error: Missing required environment variables" - echo "Required: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_FILE" - exit 1 -fi - -if [[ ! -f "$ASC_KEY_FILE" ]]; then - echo "Error: ASC_KEY_FILE not found at $ASC_KEY_FILE" - exit 1 -fi - -TEMP_KEY_DIR=$(mktemp -d) -trap "rm -rf '$TEMP_KEY_DIR'" EXIT - -cp "$ASC_KEY_FILE" "$TEMP_KEY_DIR/AuthKey_${ASC_KEY_ID}.p8" - -export API_PRIVATE_KEYS_DIR="$TEMP_KEY_DIR" - -xcrun altool --upload-app \ - --type ios \ - --file "$IPA_PATH" \ - --apiKey "$ASC_KEY_ID" \ - --apiIssuer "$ASC_ISSUER_ID" \ - --verbose - -echo "TestFlight upload completed successfully"