diff --git a/.gitignore b/.gitignore
index 8ad17e5eed7..0a5c65edaf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,3 +113,4 @@ ui/StatusQ/src/StatusQ/Core/TestConfig.qml
mobile/bin/
mobile/lib/
mobile/build/
+scripts/node_modules/
diff --git a/ci/Jenkinsfile.android b/ci/Jenkinsfile.android
index 3605a361dbc..0c7d8ddb127 100644
--- a/ci/Jenkinsfile.android
+++ b/ci/Jenkinsfile.android
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.combined b/ci/Jenkinsfile.combined
index d5c08f1f6b4..396eacd5ea4 100644
--- a/ci/Jenkinsfile.combined
+++ b/ci/Jenkinsfile.combined
@@ -1,6 +1,6 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* Object to store public URLs for description. */
urls = [:]
diff --git a/ci/Jenkinsfile.ios b/ci/Jenkinsfile.ios
index b4a0917a451..4e56196b41f 100644
--- a/ci/Jenkinsfile.ios
+++ b/ci/Jenkinsfile.ios
@@ -1,9 +1,8 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
-def isNightlyBuild = utils.isNightlyBuild()
pipeline {
@@ -20,6 +19,16 @@ pipeline {
description: 'Level of verbosity based on nimbus-build-system setup.',
choices: ['0','1','2','3']
)
+ string(
+ name: 'TESTFLIGHT_POLL_TIMEOUT',
+ description: 'TestFlight build polling timeout in minutes.',
+ defaultValue: '30'
+ )
+ string(
+ name: 'TESTFLIGHT_POLL_INTERVAL',
+ description: 'TestFlight build polling interval in seconds.',
+ defaultValue: '30'
+ )
}
options {
@@ -65,8 +74,11 @@ pipeline {
IPHONE_SDK = "iphoneos"
ARCH = "x86_64"
/* iOS app paths */
- STATUS_IOS_APP_ARTIFACT = "pkg/${utils.pkgFilename(ext: 'app.zip', arch: getArch(), version: env.VERSION, type: env.APP_TYPE)}"
+ 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}"
}
stages {
@@ -91,25 +103,41 @@ pipeline {
stage('Build iOS App') {
steps {
- sh 'make mobile-build'
+ script {
+ app.buildSignedIOS(target='mobile-build', verbose=params.VERBOSE)
+ }
}
}
stage('Package iOS App') {
steps {
sh 'mkdir -p pkg'
- sh "cd mobile/bin/ios/qt6 && zip -r ${env.WORKSPACE}/${env.STATUS_IOS_APP_ARTIFACT} Status.app"
- sh "ls -la ${env.STATUS_IOS_APP_ARTIFACT}"
+ sh "cp ${env.STATUS_IOS_IPA} ${env.STATUS_IOS_APP_ARTIFACT}"
+ sh "ls -lh ${env.STATUS_IOS_APP_ARTIFACT}"
}
}
stage('Parallel Upload') {
parallel {
- stage('Upload') {
+ stage('Upload to TestFlight') {
+ steps {
+ script {
+ def changelog = sh(script: './scripts/generate-changelog.sh', returnStdout: true).trim()
+
+ app.uploadToTestFlight(
+ ipaPath=env.STATUS_IOS_APP_ARTIFACT,
+ changelog=changelog,
+ pollTimeout=env.TESTFLIGHT_POLL_TIMEOUT,
+ pollInterval=env.TESTFLIGHT_POLL_INTERVAL
+ )
+ }
+ }
+ }
+ stage('Upload to S3') {
steps {
script {
env.PKG_URL = s5cmd.upload(env.STATUS_IOS_APP_ARTIFACT)
- jenkins.setBuildDesc(APP: env.PKG_URL)
+ jenkins.setBuildDesc(IPA: env.PKG_URL)
}
}
}
diff --git a/ci/Jenkinsfile.linux b/ci/Jenkinsfile.linux
index 78ee27379f8..c4cadc3a293 100644
--- a/ci/Jenkinsfile.linux
+++ b/ci/Jenkinsfile.linux
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* 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 06a3b383432..c69008b097e 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.29'
+library 'status-jenkins-lib@v1.9.30'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.macos b/ci/Jenkinsfile.macos
index 7b4dabe0620..9b462472e4a 100644
--- a/ci/Jenkinsfile.macos
+++ b/ci/Jenkinsfile.macos
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* 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 557ac950c6a..9af46b201a7 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.29'
+library 'status-jenkins-lib@v1.9.30'
pipeline {
agent {
diff --git a/ci/Jenkinsfile.tests-e2e b/ci/Jenkinsfile.tests-e2e
index eb2882edd92..d4bf6ad2501 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.29'
+library 'status-jenkins-lib@v1.9.30'
pipeline {
agent {
diff --git a/ci/Jenkinsfile.tests-e2e.windows b/ci/Jenkinsfile.tests-e2e.windows
index ea430d92a78..7d3c68e3469 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.29'
+library 'status-jenkins-lib@v1.9.30'
pipeline {
diff --git a/ci/Jenkinsfile.tests-nim b/ci/Jenkinsfile.tests-nim
index a7f512520db..ee3941b02ca 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.29'
+library 'status-jenkins-lib@v1.9.30'
/* 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 013708cfef5..1e94356f150 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.29'
+library 'status-jenkins-lib@v1.9.30'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/ci/Jenkinsfile.windows b/ci/Jenkinsfile.windows
index 132f77f9818..f3c9f5f54d4 100644
--- a/ci/Jenkinsfile.windows
+++ b/ci/Jenkinsfile.windows
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
-library 'status-jenkins-lib@v1.9.29'
+library 'status-jenkins-lib@v1.9.30'
/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
diff --git a/mobile/ios/Info.plist b/mobile/ios/Info.plist
index 87e85cec00d..83e270be55f 100644
--- a/mobile/ios/Info.plist
+++ b/mobile/ios/Info.plist
@@ -64,5 +64,7 @@
Status uses Media Library to save and send Images. The Media Library module internally requires permissions to Apple Music
NSFaceIDUsageDescription
Log in securely to your account.
+ ITSAppUsesNonExemptEncryption
+
diff --git a/mobile/scripts/android/sign.sh b/mobile/scripts/android/sign.sh
new file mode 100755
index 00000000000..35fc4e125b8
--- /dev/null
+++ b/mobile/scripts/android/sign.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+AAB_FILE="$1"
+
+function required_var() {
+ if [[ -z "${!1}" ]]; then
+ echo -e "ERROR: No required env variable: ${1}" 1>&2
+ exit 1
+ fi
+}
+
+required_var KEYSTORE_PATH
+required_var KEYSTORE_PASSWORD
+required_var KEY_ALIAS
+required_var KEY_PASSWORD
+
+if [[ ! -f "$AAB_FILE" ]]; then
+ echo "ERROR: AAB file not found at $AAB_FILE"
+ exit 1
+fi
+
+if [[ ! -f "$KEYSTORE_PATH" ]]; then
+ echo "ERROR: Keystore file not found at $KEYSTORE_PATH"
+ exit 1
+fi
+
+echo "Signing AAB with jarsigner..."
+jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
+ -keystore "$KEYSTORE_PATH" \
+ -storepass "$KEYSTORE_PASSWORD" \
+ -keypass "$KEY_PASSWORD" \
+ "$AAB_FILE" "$KEY_ALIAS"
+
+if [[ $? -ne 0 ]]; then
+ echo "Error: AAB signing failed"
+ exit 1
+fi
+
+echo "Verifying AAB signature..."
+VERIFY_OUTPUT=$(jarsigner -verify "$AAB_FILE" 2>&1)
+if echo "$VERIFY_OUTPUT" | grep -q "jar verified"; then
+ echo "AAB signature verification: PASSED"
+else
+ echo "Error: AAB signature verification failed"
+ echo "Verify output: $VERIFY_OUTPUT"
+ exit 1
+fi
+
+echo "AAB signed successfully: $AAB_FILE"
diff --git a/mobile/scripts/buildApp.sh b/mobile/scripts/buildApp.sh
index 7d8975970bf..9a360c459fa 100755
--- a/mobile/scripts/buildApp.sh
+++ b/mobile/scripts/buildApp.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-set -ef pipefail
+set -eo pipefail
CWD=$(realpath "$(dirname "$0")")
@@ -10,6 +10,7 @@ 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"}
echo "Building wrapperApp for ${OS}, ${ANDROID_ABI}"
@@ -60,27 +61,8 @@ if [[ "${OS}" == "android" ]]; then
exit 1
fi
- # Note: androiddeployqt --sign does not work for AAB files, so we sign with jarsigner
- echo "Signing AAB with jarsigner..."
- jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
- -keystore "$KEYSTORE_PATH" \
- -storepass "$KEYSTORE_PASSWORD" \
- -keypass "$KEY_PASSWORD" \
- "$OUTPUT_FILE" "$KEY_ALIAS"
-
- if [[ $? -ne 0 ]]; then
- echo "Error: AAB signing failed"
- exit 1
- fi
-
- VERIFY_OUTPUT=$(jarsigner -verify "$OUTPUT_FILE" 2>&1)
- if echo "$VERIFY_OUTPUT" | grep -q "jar verified"; then
- echo "AAB signature verification: PASSED"
- else
- echo "Error: AAB signature verification failed"
- echo "Verify output: $VERIFY_OUTPUT"
- exit 1
- fi
+ # Sign the AAB file (androiddeployqt --sign does not work for AAB files)
+ "$CWD/android/sign.sh" "$OUTPUT_FILE"
ANDROID_OUTPUT_DIR="bin/android/qt6"
BIN_DIR_ANDROID=${BIN_DIR:-"$CWD/$ANDROID_OUTPUT_DIR"}
@@ -128,17 +110,32 @@ if [[ "${OS}" == "android" ]]; then
fi
fi
else
+ BUILD_VERSION=$(($(date +%s) * 1000 / 60000))
+
+ if [[ -n "${CHANGE_ID:-}" ]]; then
+ VERSION_STRING="${CHANGE_ID}.${BUILD_VERSION}"
+ else
+ VERSION_STRING="${BUILD_VERSION}"
+ fi
+
+ echo "Using version: $VERSION_STRING"
+
QMAKE_BIN="${QMAKE:-qmake}"
- "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" -spec macx-ios-clang CONFIG+=release CONFIG+="$SDK" CONFIG+=device -after
+ "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" -spec macx-ios-clang CONFIG+=release CONFIG+="$SDK" CONFIG+=device VERSION="$VERSION_STRING" -after
+
# Compile resources
xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO | 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 | xcbeautify
- if [[ -e "$BIN_DIR/Status.app/Info.plist" ]]; then
- echo "Build succeeded"
- else
+ if [[ ! -e "$BIN_DIR/Status.app/Info.plist" ]]; then
echo "Build failed"
exit 1
fi
+
+ if [[ "$SIGN_IOS" == "true" ]]; then
+ "$CWD/ios/sign.sh"
+ fi
+
+ echo "Build succeeded"
fi
diff --git a/mobile/scripts/ios/sign.sh b/mobile/scripts/ios/sign.sh
new file mode 100755
index 00000000000..ebc9c22d25f
--- /dev/null
+++ b/mobile/scripts/ios/sign.sh
@@ -0,0 +1,122 @@
+#!/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 b802437cb68..9dc2f419933 100644
--- a/mobile/wrapperApp/Status.pro
+++ b/mobile/wrapperApp/Status.pro
@@ -44,8 +44,8 @@ ios {
QMAKE_INFO_PLIST = $$PWD/../ios/Info.plist
QMAKE_IOS_DEPLOYMENT_TARGET=16.0
- QMAKE_TARGET_BUNDLE_PREFIX = im.status
- QMAKE_BUNDLE = app
+ 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
diff --git a/scripts/extract-bundle-version.sh b/scripts/extract-bundle-version.sh
new file mode 100755
index 00000000000..3c5e9faf1aa
--- /dev/null
+++ b/scripts/extract-bundle-version.sh
@@ -0,0 +1,17 @@
+#!/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/generate-changelog.sh b/scripts/generate-changelog.sh
new file mode 100755
index 00000000000..9278b3e390c
--- /dev/null
+++ b/scripts/generate-changelog.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ -n "${CHANGE_ID:-}" ]]; then
+ # PR build
+ SHORT_COMMIT="${GIT_COMMIT:0:7}"
+ echo "PR #${CHANGE_ID} - ${SHORT_COMMIT}"
+ echo "Build: #${BUILD_NUMBER}"
+ echo ""
+ echo "View PR: https://github.com/status-im/status-desktop/pull/${CHANGE_ID}"
+elif [[ "${BRANCH_NAME:-}" == release* ]]; then
+ # Release branch build
+ echo "Release ${VERSION}"
+ echo "Build: #${BUILD_NUMBER}"
+else
+ # Regular branch build
+ SHORT_COMMIT="${GIT_COMMIT:0:7}"
+ echo "Branch: ${BRANCH_NAME}"
+ echo "Commit: ${SHORT_COMMIT}"
+ echo "Build: #${BUILD_NUMBER}"
+fi
diff --git a/scripts/testflight-changelog.mjs b/scripts/testflight-changelog.mjs
new file mode 100755
index 00000000000..108749dc284
--- /dev/null
+++ b/scripts/testflight-changelog.mjs
@@ -0,0 +1,236 @@
+#!/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
new file mode 100755
index 00000000000..ce049bd32c0
--- /dev/null
+++ b/scripts/upload-testflight.sh
@@ -0,0 +1,41 @@
+#!/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"