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
185 changes: 185 additions & 0 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Queue all pending TestFlight uploads

In this workflow, cancel-in-progress: false does not actually queue every requested upload: GitHub Actions concurrency groups still keep only one pending run by default, and a third run for the same ref cancels the older pending run unless the new queue setting is enabled. If a TestFlight upload is running and two manual dispatches or delayed scheduled runs for main arrive, the middle requested upload can be dropped despite the comment's "no upload is lost" guarantee; add an explicit multi-run queue (for example queue: max) or remove the workflow-level concurrency if every dispatch must upload.

Useful? React with 👍 / 👎.


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,
Comment on lines +64 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Widen the scheduled commit window

For scheduled runs, this checks only commits newer than exactly 24 hours before the job actually starts. GitHub documents that schedule workflows can be delayed or even dropped under load, so a commit that lands just after yesterday's 09:10 run can be older than 24 hours by the time today's delayed run executes, causing the nightly TestFlight upload to be skipped even though that commit was never uploaded. Consider using a wider overlap window or comparing against the last successful upload/run instead of Date.now() - 24h.

Useful? React with 👍 / 👎.

});
lastUploadedSha = runs.data.workflow_runs[0]?.head_sha ?? null;
needsBuild = lastUploadedSha !== context.sha;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skip logic ignores branch

Medium Severity

The scheduled skip check compares main’s HEAD to the latest successful run’s head_sha across all branches. After main is uploaded, a later successful manual run from another branch makes nightly jobs think main is new and re-upload the same commit, contradicting the “skip when HEAD already uploaded” behavior and wasting CI and build numbers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e44b49d. Configure here.

}

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();

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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_<KEY_ID>.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"
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ struct CMUXMobileRootView: View {
#endif

private var shouldShowTerminalLayoutPreview: Bool {
#if os(iOS)
#if os(iOS) && DEBUG
return UITestConfig.terminalLayoutPreviewEnabled
#else
return false
#endif
}

@ViewBuilder private var terminalLayoutPreview: some View {
#if os(iOS)
#if os(iOS) && DEBUG
TerminalLayoutPreviewView()
#else
EmptyView()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if canImport(UIKit)
#if canImport(UIKit) && DEBUG
import CMUXMobileCore
import CmuxMobileTerminal
import SwiftUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
46 changes: 42 additions & 4 deletions ios/scripts/upload-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ usage() {
cat <<'EOF'
Usage:
ios/scripts/upload-testflight.sh [--lane beta] [--build-number <number>]
[--signing manual|automatic]
[--archive-path <path>] [--export-only]

Archives cmux iOS, exports an App Store Connect IPA, and uploads it to
Expand Down Expand Up @@ -38,6 +39,13 @@ or:
Options:
--lane <beta> Distribution lane. Only beta is currently defined.
--build-number <number> CFBundleVersion. Defaults to UTC yyyyMMddHHmm.
--signing <mode> 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 <path> Reuse an existing archive instead of archiving.
--export-only Stop after exporting the signed IPA.
-h, --help Show this help.
Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down
Loading