iOS: registry-driven auto-attach (sign in → connected) #2076
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: iOS simulator tests | |
| on: | |
| pull_request: | |
| paths: | |
| - "ios/**" | |
| - "Packages/CMUXAuthCore/**" | |
| - "Packages/CMUXMobileCore/**" | |
| - "Packages/CmuxAuthRuntime/**" | |
| - "Packages/CmuxMobile*/**" | |
| - "Packages/CMUXMobile*/**" | |
| - "Sources/Mobile/**" | |
| - "vendor/stack-auth-swift-sdk-prerelease/**" | |
| # The namespace-type convention rule scans every package, so any package | |
| # change must run the conventions lint job (heavier iOS jobs still gate | |
| # on iOS-owned paths via should_run). | |
| - "Packages/**" | |
| - "scripts/lint-ios-package-conventions.sh" | |
| - "scripts/lint-namespace-types-baseline.txt" | |
| - ".github/workflows/test-ios.yml" | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: Branch or SHA to test | |
| required: false | |
| default: "" | |
| test_filter: | |
| description: "UI test class or class/method, for example cmuxUITests/testSignInPairingAndWorkspaceShell" | |
| required: false | |
| default: "" | |
| device_family: | |
| description: "Simulator family" | |
| required: false | |
| default: "both" | |
| type: choice | |
| options: | |
| - both | |
| - iphone | |
| - ipad | |
| permissions: | |
| contents: read | |
| jobs: | |
| detect-ios-changes: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| should_run: ${{ steps.detect.outputs.should_run }} | |
| should_lint: ${{ steps.detect.outputs.should_lint }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| submodules: false | |
| - name: Detect iOS changes | |
| id: detect | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| echo "should_run=true" >> "$GITHUB_OUTPUT" | |
| echo "should_lint=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| BASE_MERGE_BASE="$(git merge-base "$BASE_SHA" "$HEAD_SHA")" | |
| git diff --name-only "$BASE_MERGE_BASE" "$HEAD_SHA" > /tmp/changed-files.txt | |
| if grep -Eq '^(ios/|Packages/CMUXAuthCore/|Packages/CMUXMobileCore/|Packages/CmuxAuthRuntime/|Packages/CmuxMobile[^/]*/|Packages/CMUXMobile[^/]*/|Packages/CmuxSyncStore/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$)' /tmp/changed-files.txt; then | |
| echo "should_run=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No iOS-owned files changed; skipping iOS tests." | |
| echo "should_run=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # The conventions lint (free-function ban, namespace-type rule, ...) | |
| # covers every package, so it runs for any Packages/ change too. | |
| if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then | |
| echo "should_lint=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No package-owned files changed; skipping conventions lint." | |
| echo "should_lint=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| package-conventions-lint: | |
| needs: detect-ios-changes | |
| if: ${{ needs.detect-ios-changes.outputs.should_lint == 'true' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| persist-credentials: false | |
| submodules: false | |
| - name: Lint iOS package conventions | |
| run: | | |
| # Mechanical enforcement of the modular-refactor conventions over the | |
| # iOS line (no singletons / Combine / locks / timer hacks / KVO / | |
| # namespace-enums) plus the repo-wide namespace-type rule (no | |
| # all-static "namespace" types in any package). Exits non-zero on any | |
| # unjustified ERROR; sanctioned exceptions carry a lint:allow / | |
| # TRANSITIONAL / carve-out marker, and pre-existing namespace-type | |
| # debt is grandfathered in scripts/lint-namespace-types-baseline.txt. | |
| ./scripts/lint-ios-package-conventions.sh | |
| mobile-core-package: | |
| needs: detect-ios-changes | |
| if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} | |
| runs-on: macos-26 | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| persist-credentials: false | |
| submodules: false | |
| - 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: Run CMUXMobileCore package tests | |
| run: | | |
| swift test --package-path Packages/CMUXMobileCore | |
| - name: Run CmuxSyncStore package tests | |
| run: | | |
| # Local-first sync store (raw SQLite3). Headless SwiftPM, no GhosttyKit | |
| # or app-target dependency, so it runs as a real CI gate here. | |
| swift test --package-path Packages/CmuxSyncStore | |
| ios-simulator: | |
| needs: detect-ios-changes | |
| if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} | |
| runs-on: macos-26 | |
| timeout-minutes: 35 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| family: [iphone, ipad] | |
| steps: | |
| - name: Skip unrequested family | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family }} | |
| run: | | |
| echo "Skipping ${{ matrix.family }}" | |
| - name: Checkout | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.ref || github.ref }} | |
| persist-credentials: false | |
| submodules: recursive | |
| - name: Select Xcode | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| 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 | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| 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: Pick simulator | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| env: | |
| DEVICE_FAMILY: ${{ matrix.family }} | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' > /tmp/simulator.env | |
| import json | |
| import os | |
| import subprocess | |
| family = os.environ["DEVICE_FAMILY"] | |
| data = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"])) | |
| devices = [ | |
| device | |
| for runtimes in data.get("devices", {}).values() | |
| for device in runtimes | |
| if device.get("isAvailable", True) | |
| ] | |
| prefix = "iPad" if family == "ipad" else "iPhone" | |
| preferred = ["iPad Pro 13-inch (M4)", "iPad Air 13-inch (M3)"] if family == "ipad" else ["iPhone 17", "iPhone 16"] | |
| selected = next((d for name in preferred for d in devices if d.get("name") == name), None) | |
| selected = selected or next(d for d in devices if d.get("name", "").startswith(prefix)) | |
| print(f"SIMULATOR_ID={selected['udid']}") | |
| print(f"SIMULATOR_NAME={selected['name']}") | |
| PY | |
| cat /tmp/simulator.env | |
| cat /tmp/simulator.env >> "$GITHUB_ENV" | |
| - name: Resolve packages | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| run: | | |
| xcodebuild -workspace ios/cmux.xcworkspace \ | |
| -scheme cmux-ios \ | |
| -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ | |
| -derivedDataPath /tmp/cmux-ios-${{ matrix.family }} \ | |
| -resolvePackageDependencies | |
| - name: Run iOS simulator tests | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.device_family != 'both' && inputs.device_family != matrix.family) }} | |
| env: | |
| TEST_FILTER: ${{ inputs.test_filter }} | |
| run: | | |
| set -euo pipefail | |
| XCODEBUILD_ARGS=( | |
| -workspace ios/cmux.xcworkspace | |
| -scheme cmux-ios | |
| -destination "platform=iOS Simulator,id=$SIMULATOR_ID" | |
| -derivedDataPath /tmp/cmux-ios-${{ matrix.family }} | |
| ) | |
| if [ -n "${TEST_FILTER:-}" ]; then | |
| XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER") | |
| fi | |
| XCODEBUILD_ARGS+=(test) | |
| selected_tests_passed_despite_xcodebuild_status() { | |
| local log_path="$1" | |
| # The negative grep must catch BOTH XCTest failure output and Swift | |
| # Testing failure output (✘ markers): the final "Failing tests:" | |
| # section is not always flushed into the tee'd log, and without the | |
| # ✘ patterns a real Swift Testing failure exiting 65 was | |
| # misclassified as a runner cleanup failure and turned the job green. | |
| grep -Eq "Test Suite 'Selected tests' passed|Test Suite 'cmuxUITests' passed" "$log_path" && | |
| grep -Eq "Executed [1-9][0-9]* tests, with 0 failures \\(0 unexpected\\)" "$log_path" && | |
| ! grep -Eq "Test Suite '.*' failed|Test Case '.*' failed|Assertion Failure|Failing tests:|with [1-9][0-9]* failures|with [0-9]+ failures \\([1-9][0-9]* unexpected\\)|✘ Test|✘ Suite" "$log_path" | |
| } | |
| for attempt in 1 2; do | |
| LOG_PATH="/tmp/cmux-ios-${{ matrix.family }}-attempt-${attempt}.log" | |
| echo "Preparing $SIMULATOR_NAME ($SIMULATOR_ID), attempt $attempt" | |
| xcrun simctl shutdown "$SIMULATOR_ID" >/dev/null 2>&1 || true | |
| xcrun simctl erase "$SIMULATOR_ID" | |
| xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true | |
| xcrun simctl bootstatus "$SIMULATOR_ID" -b | |
| if xcodebuild "${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$LOG_PATH"; then | |
| exit 0 | |
| fi | |
| status="${PIPESTATUS[0]}" | |
| if selected_tests_passed_despite_xcodebuild_status "$LOG_PATH"; then | |
| echo "xcodebuild exited $status after XCTest reported the selected tests passed with zero failures; treating this as a runner cleanup failure." | |
| exit 0 | |
| fi | |
| if [ "$attempt" -lt 2 ] && grep -Eq "Timed out while launching application via Xcode|Failed to send signal 19|DTXMessage" "$LOG_PATH"; then | |
| echo "Detected Xcode simulator launch failure, retrying on a clean simulator." | |
| continue | |
| fi | |
| exit "$status" | |
| done |