Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 18 additions & 62 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -565,15 +567,15 @@ 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}\`"
else
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
Expand Down Expand Up @@ -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]
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion ios/Config/Shared.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions ios/scripts/upload-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down