diff --git a/.gitignore b/.gitignore
index 06723f1de92..d815587334f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,3 +120,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.android b/ci/Jenkinsfile.android
index 26797ae21d6..55447838213 100644
--- a/ci/Jenkinsfile.android
+++ b/ci/Jenkinsfile.android
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.combined b/ci/Jenkinsfile.combined
index bb75966b247..61d543bf57b 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@v1.9.32'
/* 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 3f6db923d25..4e6344fb567 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@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
@@ -72,11 +72,8 @@ pipeline {
APP_TYPE = "${utils.getAppType()}"
/* iOS build configuration */
IPHONE_SDK = "iphoneos"
- ARCH = "x86_64"
- /* iOS app paths */
+ ARCH = "arm64"
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"
TESTFLIGHT_POLL_TIMEOUT = "${params.TESTFLIGHT_POLL_TIMEOUT}"
TESTFLIGHT_POLL_INTERVAL = "${params.TESTFLIGHT_POLL_INTERVAL}"
}
@@ -112,7 +109,7 @@ pipeline {
stage('Package iOS App') {
steps {
sh 'mkdir -p pkg'
- sh "cp ${env.STATUS_IOS_IPA} ${env.STATUS_IOS_APP_ARTIFACT}"
+ sh "cp ${WORKSPACE}/mobile/bin/ios/qt6/Status${utils.isReleaseBuild() ? '' : 'PR'}.ipa ${env.STATUS_IOS_APP_ARTIFACT}"
sh "ls -lh ${env.STATUS_IOS_APP_ARTIFACT}"
}
}
@@ -120,6 +117,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()
@@ -133,11 +131,20 @@ pipeline {
}
}
}
+ stage('Upload to Diawi') {
+ when { expression { !utils.isReleaseBuild() } }
+ steps {
+ script {
+ def comment = "status-desktop PR build ${env.VERSION} ${git.commit(8)}"
+ 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/Jenkinsfile.linux b/ci/Jenkinsfile.linux
index 3e6458429b3..d972645245c 100644
--- a/ci/Jenkinsfile.linux
+++ b/ci/Jenkinsfile.linux
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.linux-nix b/ci/Jenkinsfile.linux-nix
index 5984289885b..4bc0a72787a 100644
--- a/ci/Jenkinsfile.linux-nix
+++ b/ci/Jenkinsfile.linux-nix
@@ -1,6 +1,6 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.macos b/ci/Jenkinsfile.macos
index 9a475b74bfc..056c6c3e27d 100644
--- a/ci/Jenkinsfile.macos
+++ b/ci/Jenkinsfile.macos
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.qt-build b/ci/Jenkinsfile.qt-build
index 21acdfba046..c02af712bdc 100644
--- a/ci/Jenkinsfile.qt-build
+++ b/ci/Jenkinsfile.qt-build
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
pipeline {
agent {
diff --git a/ci/Jenkinsfile.tests-e2e b/ci/Jenkinsfile.tests-e2e
index 985f606d8c2..ffe517ab727 100644
--- a/ci/Jenkinsfile.tests-e2e
+++ b/ci/Jenkinsfile.tests-e2e
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
pipeline {
agent {
diff --git a/ci/Jenkinsfile.tests-e2e.windows b/ci/Jenkinsfile.tests-e2e.windows
index 9e55280a7d4..e10851e83ed 100644
--- a/ci/Jenkinsfile.tests-e2e.windows
+++ b/ci/Jenkinsfile.tests-e2e.windows
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
pipeline {
diff --git a/ci/Jenkinsfile.tests-nim b/ci/Jenkinsfile.tests-nim
index d864fdc1844..8133138651e 100644
--- a/ci/Jenkinsfile.tests-nim
+++ b/ci/Jenkinsfile.tests-nim
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.tests-ui b/ci/Jenkinsfile.tests-ui
index 41cf7711a6d..3927bd23ecf 100644
--- a/ci/Jenkinsfile.tests-ui
+++ b/ci/Jenkinsfile.tests-ui
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.windows b/ci/Jenkinsfile.windows
index 0d8ac2b34d0..5d151dee965 100644
--- a/ci/Jenkinsfile.windows
+++ b/ci/Jenkinsfile.windows
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.31'
+library 'status-jenkins-lib@v1.9.32'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
@@ -96,6 +96,7 @@ pipeline {
USE_MOCKED_KEYCARD_LIB = "${params.USE_MOCKED_KEYCARD_LIB}"
/* FIXME: figure out why NIMFLAGS are not respected */
XDG_CACHE_HOME = "${env.WORKSPACE_TMP}/.cache"
+ NIM_SDS_SOURCE_DIR = "${env.WORKSPACE_TMP}/nim-sds".replace('\\', '/')
}
stages {
diff --git a/ci/README.md b/ci/README.md
index 65607bf0c40..a7d5866969d 100644
--- a/ci/README.md
+++ b/ci/README.md
@@ -26,3 +26,10 @@ 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.
+
+For detailed documentation on iOS signing, fastlane configuration, and certificate management, see [`mobile/fastlane/README.md`](../mobile/fastlane/README.md).
+
diff --git a/mobile/fastlane/Appfile b/mobile/fastlane/Appfile
new file mode 100644
index 00000000000..b63d3202de9
--- /dev/null
+++ b/mobile/fastlane/Appfile
@@ -0,0 +1,7 @@
+# App identifiers for Status App iOS builds
+app_identifier("app.status.mobile")
+team_id("8B5X2M6H2Y")
+
+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..10ff3604c33
--- /dev/null
+++ b/mobile/fastlane/Fastfile
@@ -0,0 +1,178 @@
+# 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) rescue nil
+ 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"
+ )
+ 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
+
+ # Clean up any stale temp directory from previous resign attempts
+ # https://github.com/fastlane/fastlane/blob/512a047abd596a5c2c0e0156e9f52e111552a727/sigh/lib/assets/resign.sh#L177C1-L177C9
+ FileUtils.rm_rf("_floatsignTemp")
+
+ # Call resign.sh directly with /bin/bash instead of using the resign action
+ # Ruby backticks use /bin/sh which doesn't support bash process substitution < <(...)
+ # that resign.sh uses for processing nested apps/extensions
+ resign_sh = File.join(Sigh::ROOT, 'lib', 'assets', 'resign.sh')
+
+ UI.message("Resigning IPA with resign.sh directly via bash...")
+ command = [
+ resign_sh.shellescape,
+ ipa_path.shellescape,
+ signing_identity.shellescape,
+ "-p", "#{app_identifier}=#{provisioning_profile}".shellescape,
+ "-d", display_name.shellescape,
+ "-b", app_identifier.shellescape,
+ "-v",
+ ipa_path.shellescape # output path (same as input, overwrites)
+ ].join(' ')
+
+ UI.message("Command: #{command}")
+
+ # Use sh() which properly executes via the shell, wrapped in bash -c
+ sh("/bin/bash", "-c", command)
+
+ 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"]
+ 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..6df9f17764b
--- /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-ios-certs.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..079ce7ca1f4
--- /dev/null
+++ b/mobile/fastlane/README.md
@@ -0,0 +1,76 @@
+# iOS Fastlane Configuration
+
+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
+
+| File | Purpose |
+|-------------|----------------------------------------------|
+| `Fastfile` | Defines signing lanes (`pr`, `release`) |
+| `Matchfile` | Configures match for certificate management |
+| `Appfile` | App identifiers and team configuration |
+| `Gemfile` | Ruby dependencies |
+
+## Available Actions
+
+### 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
+
+## 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/flake.lock b/mobile/fastlane/flake.lock
new file mode 100644
index 00000000000..dbb6bec9532
--- /dev/null
+++ b/mobile/fastlane/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "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": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/mobile/fastlane/flake.nix b/mobile/fastlane/flake.nix
new file mode 100644
index 00000000000..e88cb190909
--- /dev/null
+++ b/mobile/fastlane/flake.nix
@@ -0,0 +1,65 @@
+{
+ description = "Fastlane environment for iOS signing";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ supportedSystems = [
+ "x86_64-linux" "aarch64-linux"
+ "x86_64-darwin" "aarch64-darwin"
+ ];
+ forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
+ pkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
+ in
+ {
+ devShells = forAllSystems (system:
+ let
+ pkgs = pkgsFor.${system};
+ # Pin Ruby 3.1 for fastlane
+ ruby = pkgs.ruby_3_1;
+ in
+ {
+ 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 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..36ead6c33f5 100755
--- a/mobile/scripts/buildApp.sh
+++ b/mobile/scripts/buildApp.sh
@@ -10,12 +10,17 @@ 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
+export BUILD_VARIANT=${BUILD_VARIANT:-"release"}
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 +31,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 +47,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 +127,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 57f06c80d8f..ac1f695114f 100644
--- a/mobile/wrapperApp/Status.pro
+++ b/mobile/wrapperApp/Status.pro
@@ -46,11 +46,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 -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"