From b8da97a03830c0adcac18f464693f9a1e2f7616a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:27:18 -0700 Subject: [PATCH] Unblock internal TestFlight: strip embedded static frameworks, fix assign jobs Three stacked failures kept every internal beta out of TestFlight: 1. ITMS-90208 processing rejection: iroh-ffi's Iroh.framework binary is a static archive, and Xcode embeds it into cmux.app/Frameworks/ anyway. ASC validates the embedded framework BINARY, which has no Mach-O minimum-OS load command, so every upload containing Iroh was rejected in processing regardless of deployment target or the framework's Info.plist. The re-sign path now strips embedded static-archive frameworks (their code is already statically linked into the app executable; a load-command gate proves it), and the IPA verifier hard-fails if one is still embedded. 2. Internal group assignment failed instantly: the workflow passed both CMUX_TESTFLIGHT_INTERNAL_GROUP_ID and _NAME, and the assign script errors when both are set. Only the id is passed now. 3. assign-external-group hung 40 minutes per run polling dev.cmux.app.beta for builds that now upload to dev.cmux.app.internal. Job removed; the decide job's assign-only retry logic now keys off the internal assignment job, and the internal job uploads the assignment-state artifact name decide checks. Also restores IPHONEOS_DEPLOYMENT_TARGET to 18.4. The 18.0/17.5 lowering chased the ITMS-90208 symptom at the wrong layer, and 17.5 broke the build outright (packages declare .iOS(.v18); run #509 failed with 'compiling for iOS 17.5, but module CMUXMobileCore has a minimum deployment target of iOS 18.0'). Co-Authored-By: Claude Fable 5 --- .github/workflows/ios-testflight.yml | 80 +++++++--------------------- ios/Config/Shared.xcconfig | 7 ++- ios/scripts/upload-testflight.sh | 37 +++++++++++++ 3 files changed, 61 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 84762554a858..2f673004a13d 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -161,7 +161,9 @@ jobs: lastUploadedSha = run.head_sha; lastUploadedRunId = String(run.id); const assignJob = jobs.data.jobs.find( - (job) => job.name === 'Assign build to external TestFlight group' + (job) => + job.name === 'Assign build to internal TestFlight group' || + job.name === 'Assign build to external TestFlight group' ); // Older successful upload runs predate the external-assignment // job and metadata artifact entirely. Those runs uploaded the @@ -565,7 +567,7 @@ jobs: { echo "### iOS TestFlight upload" echo - echo "- lane: \`beta\` (bundle id \`dev.cmux.app.beta\`, external-eligible)" + echo "- lane: \`beta\` (bundle id \`dev.cmux.app.internal\`, internal TestFlight)" echo "- signing: manual (CI-imported iOS distribution cert + beta profile)" if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then echo "- marketing version override: \`${INPUT_MARKETING_VERSION_OVERRIDE}\`" @@ -573,7 +575,7 @@ jobs: echo "- marketing version: checked-in beta marketing version" fi echo "- build number (CFBundleVersion): \`${BUILD_NUMBER}\`" - echo "- audience: internal testers immediately, external testers automatically after external-group assignment; new beta marketing versions are auto-submitted for Apple Beta App Review" + echo "- audience: internal TestFlight group (cmux INTERNAL) on the dev.cmux.app.internal app; no beta review needed" } >> "$GITHUB_STEP_SUMMARY" - name: Persist uploaded build metadata @@ -605,63 +607,10 @@ jobs: run: | security delete-keychain ios-testflight.keychain >/dev/null 2>&1 || true - assign-external-group: - name: Assign build to external TestFlight group - needs: [decide, upload] - if: always() && (needs.decide.outputs.should_build == 'true' || needs.decide.outputs.should_assign_only == 'true') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/ios-hotfix-chip-top') && (needs.upload.result == 'success' || needs.decide.outputs.should_assign_only == 'true') - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 40 - env: - ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} - ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} - ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} - CMUX_TESTFLIGHT_EXTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_ID }} - CMUX_TESTFLIGHT_EXTERNAL_GROUP_NAME: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_NAME }} - GH_TOKEN: ${{ github.token }} - SHOULD_BUILD: ${{ needs.decide.outputs.should_build }} - SHOULD_ASSIGN_ONLY: ${{ needs.decide.outputs.should_assign_only }} - LAST_UPLOADED_RUN_ID: ${{ needs.decide.outputs.last_uploaded_run_id }} - BUILD_NUMBER: ${{ needs.upload.outputs.final_build_number }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Restore previous uploaded build metadata - if: env.SHOULD_ASSIGN_ONLY == 'true' - run: | - set -euo pipefail - gh run download "$LAST_UPLOADED_RUN_ID" --repo manaflow-ai/cmux \ - -n ios-testflight-build-metadata \ - -D "$RUNNER_TEMP/ios-testflight-build" - BUILD_NUMBER="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["build_number"])' "$RUNNER_TEMP/ios-testflight-build/ios-testflight-build.json")" - echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - - - name: Assign uploaded build to the external beta group - id: assign - run: | - set -euo pipefail - export CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE="$RUNNER_TEMP/ios-testflight-assign-state.txt" - if [ -z "${BUILD_NUMBER:-}" ] || [ "$BUILD_NUMBER" = "unknown" ]; then - echo "missing uploaded build number for external TestFlight assignment" >&2 - exit 1 - fi - python3 ./ios/scripts/asc_assign_external_testflight_group.py \ - --bundle-id dev.cmux.app.beta \ - --build-number "$BUILD_NUMBER" - ASSIGNMENT_STATE="unknown" - if [ -f "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE" ]; then - ASSIGNMENT_STATE="$(cat "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE")" - fi - echo "assignment_state=$ASSIGNMENT_STATE" >> "$GITHUB_OUTPUT" - - - name: Upload assignment-state artifact - if: success() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ steps.assign.outputs.assignment_state == 'sibling_review_pending' && 'ios-testflight-assignment-state-pending' || 'ios-testflight-assignment-state-complete' }} - path: ${{ runner.temp }}/ios-testflight-assign-state.txt - retention-days: 30 - + # NOTE: this lane uploads to dev.cmux.app.internal only, so there is no + # external-group assignment job here. The old assign-external-group job + # polled dev.cmux.app.beta for a build that now never arrives there and hung + # for its full 40-minute timeout on every run. assign-internal-group: name: Assign build to internal TestFlight group needs: [decide, upload] @@ -672,8 +621,12 @@ jobs: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + # Pass ONLY the group id. asc_assign_internal_testflight_group.py reads + # both CMUX_TESTFLIGHT_INTERNAL_GROUP_ID and _NAME env vars as argparse + # defaults and hard-errors when both are set ("set only one of --group-id + # or --group-name"), which failed every internal assignment while both + # repo variables were wired in. CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }} - CMUX_TESTFLIGHT_INTERNAL_GROUP_NAME: ${{ vars.IOS_TESTFLIGHT_INTERNAL_GROUP_NAME }} GH_TOKEN: ${{ github.token }} SHOULD_BUILD: ${{ needs.decide.outputs.should_build }} SHOULD_ASSIGN_ONLY: ${{ needs.decide.outputs.should_assign_only }} @@ -715,6 +668,9 @@ jobs: if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ios-testflight-internal-assignment-state-complete + # The decide job's assign-only retry logic looks for this exact + # artifact name (internal groups have no beta-review "pending" state, + # so a successful run is always complete). + name: ios-testflight-assignment-state-complete path: ${{ runner.temp }}/ios-testflight-assign-state.txt retention-days: 30 diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig index b5d96e3b580d..2eed0a960e30 100644 --- a/ios/Config/Shared.xcconfig +++ b/ios/Config/Shared.xcconfig @@ -58,7 +58,12 @@ CMUX_CRASH_REPORTING_ENABLED = YES // ========================================== // Platform Configuration // ========================================== -IPHONEOS_DEPLOYMENT_TARGET = 17.5 +// Keep >= 18.0: every cmux iOS Swift package declares .iOS(.v18), so a lower +// app target fails the build. The ITMS-90208 rejections that motivated the +// brief 18.0/17.5 lowering were caused by an embedded static-archive +// Iroh.framework (stripped in ios/scripts/upload-testflight.sh), not by this +// value. +IPHONEOS_DEPLOYMENT_TARGET = 18.4 // (1 == iPhone, 2 == iPad) TARGETED_DEVICE_FAMILY = 1,2 diff --git a/ios/scripts/upload-testflight.sh b/ios/scripts/upload-testflight.sh index 95fe01c95abd..6ce0e05f22a3 100755 --- a/ios/scripts/upload-testflight.sh +++ b/ios/scripts/upload-testflight.sh @@ -150,6 +150,18 @@ verify_ipa_framework_minimum_os_versions() { while IFS= read -r -d '' framework; do framework_name="$(basename "$framework")" + # ASC validates the framework BINARY, not just Info.plist: an embedded + # framework whose binary is a static archive has no Mach-O minimum-OS load + # command and is rejected in processing (ITMS-90208) even when its + # Info.plist declares MinimumOSVersion. Static code is already linked into + # the app executable; the manual re-sign path strips these, so reaching + # this check with one still embedded is a hard error. + framework_binary="$framework/$(basename "$framework" .framework)" + if [[ -f "$framework_binary" ]] && file -b "$framework_binary" | grep -q 'ar archive'; then + echo "error: $framework_name is embedded in the app bundle but its binary is a static archive; ASC rejects this (ITMS-90208). It must be stripped from Frameworks/ (its code is already statically linked into the app executable)." >&2 + rm -rf "$workdir" + return 1 + fi plist="$framework/Info.plist" if [[ ! -f "$plist" ]]; then echo "error: $framework_name is missing Info.plist" >&2 @@ -992,6 +1004,31 @@ if [[ "$SIGNING" == "manual" ]]; then exit 1 fi + # Xcode embeds SPM binaryTarget frameworks into Frameworks/ even when the + # framework's binary is a STATIC archive (ar), e.g. iroh-ffi's Iroh.framework. + # The linker already folded that code into the app executable, so the embedded + # copy is inert dead weight — and App Store Connect rejects it in processing + # (ITMS-90208: a static archive has no Mach-O minimum-OS load command, so ASC + # reads "does not support the minimum OS Version" regardless of the + # deployment target or the framework's Info.plist). Strip such frameworks + # before re-signing. Gate: prove the app executable does not reference a + # stripped framework in its dynamic load commands (it cannot, for an ar + # archive, but verify rather than assume). + RESIGN_APP_EXECUTABLE="$RESIGN_APP/$("$PLISTBUDDY" -c 'Print :CFBundleExecutable' "$RESIGN_APP/Info.plist")" + while IFS= read -r -d '' embedded_fw; do + embedded_fw_name="$(basename "$embedded_fw" .framework)" + embedded_fw_bin="$embedded_fw/$embedded_fw_name" + [[ -f "$embedded_fw_bin" ]] || continue + if file -b "$embedded_fw_bin" | grep -q 'ar archive'; then + if otool -L "$RESIGN_APP_EXECUTABLE" | grep -qF "/${embedded_fw_name}.framework/"; then + echo "error: app executable dynamically links ${embedded_fw_name}.framework but the embedded binary is a static archive; refusing to strip or upload" >&2 + exit 1 + fi + echo "stripping embedded static-archive framework (already statically linked into the app executable; ASC rejects it as an embedded framework): Frameworks/${embedded_fw_name}.framework" + rm -rf "$embedded_fw" + fi + done < <(find "$RESIGN_APP/Frameworks" -maxdepth 1 -type d -name '*.framework' -print0 2>/dev/null) + # Start from the exported app's current (profile-baseline) entitlements, then # MERGE the profile's authorized Entitlements dict, then every key from the # Release entitlements file. The merge is GENERIC: PlistBuddy Merge copies all