diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 000000000000..d8ceeb9854ef --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,185 @@ +name: iOS TestFlight (beta) + +on: + workflow_dispatch: + inputs: + build_number: + description: CFBundleVersion to stamp (defaults to UTC yyyyMMddHHmm) + required: false + default: "" + force: + # Manual (workflow_dispatch) runs always upload, so this only documents + # intent. It exists so the no-new-commits skip can be bypassed if the + # 24h commit-window check is ever extended to dispatch runs. + description: Force an upload (manual runs already always upload) + required: false + default: false + type: boolean + schedule: + # Nightly at 09:10 UTC. The decide job skips the run only when the current + # main HEAD was already uploaded by a prior successful run (SHA compare, not + # a wall-clock window), so a failed or missed nightly retries the + # not-yet-uploaded commit instead of permanently stranding it. + - cron: "10 9 * * *" + +concurrency: + group: ios-testflight-${{ github.ref_name }} + # Queue concurrent runs instead of canceling them so no upload is lost. + cancel-in-progress: false + +permissions: + contents: read + # decide reads this workflow's prior run history to find the last uploaded SHA. + actions: read + +jobs: + decide: + name: Decide whether a TestFlight upload is needed + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_build: ${{ steps.decide.outputs.should_build }} + steps: + - name: Decide whether a TestFlight upload is needed + id: decide + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} + with: + script: | + const forceBuild = process.env.FORCE_BUILD === 'true'; + const { owner, repo } = context.repo; + + // workflow_dispatch always builds (the operator asked for it). + // Scheduled runs build unless the current commit has ALREADY been + // uploaded by a prior successful run. We compare HEAD to the head_sha + // of the most recent successful run of this workflow, not a wall-clock + // window: a failed or missed nightly leaves the last success on an + // older SHA, so the next run retries the un-uploaded commit instead of + // stranding it. A successful run either uploaded HEAD or correctly + // skipped an already-uploaded HEAD, so its head_sha is always an + // uploaded commit. + let needsBuild = true; + let lastUploadedSha = null; + if (!forceBuild && context.eventName === 'schedule') { + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'ios-testflight.yml', + status: 'success', + per_page: 1, + }); + lastUploadedSha = runs.data.workflow_runs[0]?.head_sha ?? null; + needsBuild = lastUploadedSha !== context.sha; + } + + const shouldBuild = forceBuild || context.eventName === 'workflow_dispatch' || needsBuild; + core.setOutput('should_build', shouldBuild ? 'true' : 'false'); + core.summary + .addHeading('iOS TestFlight upload decision') + .addTable([ + [{ data: 'event', header: true }, context.eventName], + [{ data: 'force', header: true }, String(forceBuild)], + [{ data: 'head sha', header: true }, context.sha], + [{ data: 'last uploaded sha (schedule only)', header: true }, String(lastUploadedSha)], + [{ data: 'should build', header: true }, String(shouldBuild)], + ]) + .write(); + + upload: + name: Upload to TestFlight + needs: decide + if: needs.decide.outputs.should_build == 'true' + runs-on: macos-26 + timeout-minutes: 60 + env: + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(find /Applications -maxdepth 1 -type d -name 'Xcode*.app' -print 2>/dev/null | sort | tail -n 1 || true)" + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + + - name: Provision GhosttyKit + run: | + # Downloads the prebuilt GhosttyKit.xcframework pinned in + # scripts/ghosttykit-checksums.txt for the current ghostty SHA, or + # falls back to a from-source build. The iOS app links GhosttyKit via + # a local-path binaryTarget, so it must exist before package resolve. + ./scripts/install-zig-ci.sh + ./scripts/ensure-ghosttykit.sh + + - name: Materialize App Store Connect API key + env: + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + run: | + set -euo pipefail + if [ -z "${ASC_API_KEY_ID:-}" ] || [ -z "${ASC_API_ISSUER_ID:-}" ] || [ -z "${ASC_API_KEY_P8_BASE64:-}" ]; then + echo "Missing one of ASC_API_KEY_ID / ASC_API_ISSUER_ID / ASC_API_KEY_P8_BASE64 secrets" >&2 + exit 1 + fi + # xcodebuild -allowProvisioningUpdates and altool both look for the key + # under ~/.appstoreconnect/private_keys/AuthKey_.p8. + KEY_DIR="$HOME/.appstoreconnect/private_keys" + KEY_PATH="$KEY_DIR/AuthKey_${ASC_API_KEY_ID}.p8" + mkdir -p "$KEY_DIR" + # Decode without echoing the key contents to the log. + printf '%s' "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" + chmod 600 "$KEY_PATH" + # Write the expanded path ($HOME, not ~) so later steps can read it. + echo "ASC_API_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Resolve build number + id: build_number + env: + INPUT_BUILD_NUMBER: ${{ github.event.inputs.build_number }} + run: | + set -euo pipefail + BN="${INPUT_BUILD_NUMBER:-}" + if [ -z "$BN" ]; then + BN="$(date -u +%Y%m%d%H%M)" + fi + echo "build_number=$BN" >> "$GITHUB_OUTPUT" + echo "Using CFBundleVersion: $BN" + + - name: Archive, export, and upload to TestFlight + env: + BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} + run: | + set -euo pipefail + ./ios/scripts/upload-testflight.sh \ + --lane beta \ + --signing automatic \ + --build-number "$BUILD_NUMBER" + + - name: Summary + if: always() + env: + BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} + run: | + { + echo "### iOS TestFlight upload" + echo + echo "- lane: \`beta\` (bundle id \`dev.cmux.app.beta\`)" + echo "- signing: automatic (cloud-managed via ASC API key)" + echo "- build number (CFBundleVersion): \`${BUILD_NUMBER}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 8d055e66f79b..0e4c4322ea4d 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -26,7 +26,7 @@ struct CMUXMobileRootView: View { #endif private var shouldShowTerminalLayoutPreview: Bool { - #if os(iOS) + #if os(iOS) && DEBUG return UITestConfig.terminalLayoutPreviewEnabled #else return false @@ -34,7 +34,7 @@ struct CMUXMobileRootView: View { } @ViewBuilder private var terminalLayoutPreview: some View { - #if os(iOS) + #if os(iOS) && DEBUG TerminalLayoutPreviewView() #else EmptyView() diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift index 38533ec95502..c52e46e48f7d 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLayoutPreviewView.swift @@ -1,4 +1,4 @@ -#if canImport(UIKit) +#if canImport(UIKit) && DEBUG import CMUXMobileCore import CmuxMobileTerminal import SwiftUI diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 0a66802002eb..176bd2599296 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1562,8 +1562,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return candidates.max { $0.utf8.count < $1.utf8.count } } + #endif + /// Read the surface text for `pointTag` from the raw handle. Pure libghostty /// C calls, safe to run off the main actor on the serial output queue. + /// + /// Intentionally not `#if DEBUG`-gated: the non-DEBUG, release-shipping + /// ``visibleTerminalSnapshot()`` (Copy Debug Logs) calls this, so gating it + /// out breaks the Release/TestFlight archive while compiling fine in Debug. nonisolated static func surfaceText(_ surface: ghostty_surface_t, pointTag: ghostty_point_tag_e) -> String? { let topLeft = ghostty_point_s(tag: pointTag, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0) let bottomRight = ghostty_point_s(tag: pointTag, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0) @@ -1574,7 +1580,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { guard let ptr = text.text, text.text_len > 0 else { return "" } return String(decoding: Data(bytes: ptr, count: Int(text.text_len)), as: UTF8.self) } - #endif func renderedHTMLForTesting(pointTag: ghostty_point_tag_e = GHOSTTY_POINT_VIEWPORT) -> String? { _ = pointTag diff --git a/ios/scripts/upload-testflight.sh b/ios/scripts/upload-testflight.sh index ba61a8e76ae7..38e2778268b7 100755 --- a/ios/scripts/upload-testflight.sh +++ b/ios/scripts/upload-testflight.sh @@ -5,6 +5,7 @@ usage() { cat <<'EOF' Usage: ios/scripts/upload-testflight.sh [--lane beta] [--build-number ] + [--signing manual|automatic] [--archive-path ] [--export-only] Archives cmux iOS, exports an App Store Connect IPA, and uploads it to @@ -38,6 +39,13 @@ or: Options: --lane Distribution lane. Only beta is currently defined. --build-number CFBundleVersion. Defaults to UTC yyyyMMddHHmm. + --signing Export signing mode: manual (default) or automatic. + manual uses the "Apple Distribution" certificate and + the "cmux Beta Distribution" provisioning profile from + the local keychain (for local/dev exports). automatic + uses Xcode cloud-managed signing via the ASC API key + and -allowProvisioningUpdates, so CI does not need an + iOS distribution cert/profile in the keychain. --archive-path Reuse an existing archive instead of archiving. --export-only Stop after exporting the signed IPA. -h, --help Show this help. @@ -58,6 +66,10 @@ LANE="beta" BUILD_NUMBER="$(date -u +%Y%m%d%H%M)" ARCHIVE_PATH="" EXPORT_ONLY=0 +# Export signing mode. "manual" keeps the original local-keychain behavior; +# "automatic" switches the export to Xcode cloud-managed signing (used by CI, +# which has no iOS distribution cert/profile, only the ASC API key). +SIGNING="manual" while [[ $# -gt 0 ]]; do case "$1" in @@ -71,6 +83,11 @@ while [[ $# -gt 0 ]]; do BUILD_NUMBER="$2" shift 2 ;; + --signing) + require_option_value "$1" "${2:-}" + SIGNING="$2" + shift 2 + ;; --archive-path) require_option_value "$1" "${2:-}" ARCHIVE_PATH="$2" @@ -104,6 +121,15 @@ case "$LANE" in ;; esac +case "$SIGNING" in + manual|automatic) ;; + *) + echo "error: unsupported signing mode '$SIGNING' (expected manual or automatic)" >&2 + usage >&2 + exit 2 + ;; +esac + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" IOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" WORKSPACE="$IOS_DIR/cmux.xcworkspace" @@ -160,16 +186,28 @@ mkdir -p "$EXPORT_PATH" rm -f "$EXPORT_OPTIONS" touch "$EXPORT_OPTIONS" plutil -create xml1 "$EXPORT_OPTIONS" +# Keys common to both signing modes. plutil -insert method -string app-store-connect "$EXPORT_OPTIONS" plutil -insert destination -string export "$EXPORT_OPTIONS" plutil -insert teamID -string "$DEVELOPMENT_TEAM" "$EXPORT_OPTIONS" plutil -insert manageAppVersionAndBuildNumber -bool NO "$EXPORT_OPTIONS" plutil -insert testFlightInternalTestingOnly -bool YES "$EXPORT_OPTIONS" plutil -insert uploadSymbols -bool YES "$EXPORT_OPTIONS" -plutil -insert signingStyle -string manual "$EXPORT_OPTIONS" -plutil -insert signingCertificate -string "Apple Distribution" "$EXPORT_OPTIONS" -/usr/libexec/PlistBuddy -c "Add :provisioningProfiles dict" "$EXPORT_OPTIONS" -/usr/libexec/PlistBuddy -c "Add :provisioningProfiles:$PRODUCT_BUNDLE_IDENTIFIER string $PROVISIONING_PROFILE_NAME" "$EXPORT_OPTIONS" +if [[ "$SIGNING" == "automatic" ]]; then + # Cloud-managed signing: Xcode mints the distribution cert/profile on demand + # via the ASC API key + -allowProvisioningUpdates (already passed below), so + # the runner needs no iOS distribution cert/profile in its keychain. The + # signingCertificate/provisioningProfiles keys must be omitted in this mode; + # naming a profile that isn't installed makes -exportArchive fail. + plutil -insert signingStyle -string automatic "$EXPORT_OPTIONS" +else + # Manual signing: requires the "Apple Distribution" certificate and the named + # provisioning profile to already be present in the local keychain. + plutil -insert signingStyle -string manual "$EXPORT_OPTIONS" + plutil -insert signingCertificate -string "Apple Distribution" "$EXPORT_OPTIONS" + /usr/libexec/PlistBuddy -c "Add :provisioningProfiles dict" "$EXPORT_OPTIONS" + /usr/libexec/PlistBuddy -c "Add :provisioningProfiles:$PRODUCT_BUNDLE_IDENTIFIER string $PROVISIONING_PROFILE_NAME" "$EXPORT_OPTIONS" +fi xcodebuild -exportArchive \ -archivePath "$ARCHIVE_PATH" \