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"