DEV Community

Cover image for building and testing an iOS app without xcode
emi
emi

Posted on

building and testing an iOS app without xcode

TL;DR

I build a native swiftui iOS app through a coding agent on a linux VM with no xcode. it
ships to testflight from a hosted macos runner. this is how the work splits across
machines, and what a machine cannot prove.

  1. type-check locally by compiling for macos. swiftui, foundation, observation, and security exist on macos, so almost all of an iOS app type-checks with only the command line tools. iOS-only APIs get signature-only shims the real target never sees.
  2. run foundation-only logic under linux swift in docker. view models, parsers, and geometry with no uikit dependency run as ordinary swiftpm tests.
  3. build and test for real on a hosted macos runner. generate the xcode project from YAML, pick whatever simulator the runner has, run the xctest target, upload the .xcresult and rendered screenshots.
  4. sign and upload from the same runner with an app store connect API key and automatic signing, then poll app store connect until Apple says the build is valid.
  5. leave the phone to the human, and report anything the machine could not run as "blocked", NEVER "passed".

building and testing an iOS app without xcode

for anyone whose iOS code is written where xcode is not: a linux VM, a CI box, a mac with only the command line tools, a coding agent on a server. assumes a paid apple developer account, github actions, and swift 6.

0. what each layer proves

question what answers it where it can run
is this swift syntactically valid? swiftc -frontend -parse anywhere with a swift compiler, linux included
is it well-typed, swift 6 concurrency included? swiftc -typecheck against a real SDK macos command line tools, or linux for foundation-only files
does this logic produce the right values? xctest or swift test linux for foundation-only code, a macos runner for anything touching uikit or swiftui
does the app build, link, and pass its tests? xcodebuild test in a simulator hosted macos runner
does a screen look right? rendered images attached to tests rendered on the runner, reviewed anywhere
does it work on a phone, with voiceover, at the largest text size? a human with a device only there

put the layer name next to every acceptance criterion. "CI is green" then means exactly what it proved and nothing more.

1. type-checking locally by compiling for macos

the command line tools ship a swift 6 compiler and the macos SDK but no iOS SDK, so xcodebuild cannot build an iOS target. swiftc -typecheck for macos can still check nearly all of it.

a script copies app and test sources to a staging directory, blanks every #Preview { ... } block line by line so diagnostics keep their line numbers (the macro needs an xcode-only plugin), drops @testable import App because tests compile into the same module, rewrites import XCTest to import Foundation, and runs:

swiftc -typecheck \
  -target arm64-apple-macos14.0 \
  -swift-version 6 \
  -strict-concurrency=complete \
  -enable-upcoming-feature ExistentialAny \
  staging/App/**/*.swift \
  staging/TypecheckShims/*.swift \
  staging/Tests/**/*.swift \
  staging/TestTypecheckShims/*.swift
Enter fullscreen mode Exit fullscreen mode

the shims. one file declares what macos lacks - NavigationBarItem.TitleDisplayMode, EditMode and its environment key, the text-input modifiers. another declares XCTestCase and XCTAssert* as signatures only. neither is in the project spec, so the iOS build never sees them. 2 rules: mirror the real signature exactly, and shim only where no cross-platform API exists — otherwise change the app (imageio instead of UIImage, for example). a file that exists because of a uikit type, like a UIViewControllerRepresentable photo picker, gets excluded rather than shimmed.

use swift 6 language mode, not swift 5 with strict concurrency. the first thing this caught was a shared static let ISO8601DateFormatter, a non-Sendable reference type used across concurrent decodes. swift 6 rejects it; swift 5 only warns, and a warning in a script nobody watches is no signal.

what it cannot tell you. nothing is compiled to a binary, linked, or run. a wrong shim is a false pass until the first real xcodebuild, and preview bodies are unchecked.

2. foundation-only logic under linux swift

with no mac at all, docker gives you a real swift toolchain:

docker run --rm -v "$PWD":/work -w /work swift:6.2.3-noble \
  swiftc -frontend -parse -swift-version 6 ios/App/**/*.swift
Enter fullscreen mode Exit fullscreen mode

that is a parse check only, since there is no swiftui on linux. for actual tests, make a throwaway swiftpm package holding only the files that import nothing beyond foundation: models, formatters, view models, geometry helpers, and their tests. this only works if view models do not import swiftui, so make that a rule from the start.

3. a generated xcode project

without xcode you cannot open a .pbxproj. describe the project in ios/project.yml and generate it with xcodegen on whatever machine builds:

name: App
options:
  deploymentTarget:
    iOS: '17.0'
settings:
  base:
    SWIFT_VERSION: '6.0'
    SWIFT_STRICT_CONCURRENCY: complete
    SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY: true
    ENABLE_USER_SCRIPT_SANDBOXING: true
targets:
  App:
    type: application
    platform: iOS
    configFiles:
      Release: Release.xcconfig
Enter fullscreen mode Exit fullscreen mode

gitignore the generated .xcodeproj. build settings become a reviewable YAML diff, and the type-check script reads the same YAML for its flags. the trap: any setting changed through xcode's UI is lost on the next xcodegen generate.

4. build and test on a hosted macos runner

github's macos-15 runners have xcode:

ios-simulator:
  runs-on: macos-15
  timeout-minutes: 30
  steps:
    - uses: actions/checkout@v5
    - run: brew install xcodegen
    - run: xcodegen generate
      working-directory: ios
    - name: Select an available iPhone simulator
      run: |
        udid="$(xcrun simctl list devices available --json \
          | jq -r '[.devices[] | .[] | select(.isAvailable and (.name | startswith("iPhone")))] | first | .udid')"
        [[ -n "$udid" && "$udid" != "null" ]] || { xcrun simctl list devices available; exit 1; }
        echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV"
    - name: Build and run iOS tests
      run: |
        xcodebuild test -project ios/App.xcodeproj -scheme App -configuration Debug \
          -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
          CODE_SIGNING_ALLOWED=NO \
          -resultBundlePath build/AppTests.xcresult
    - uses: actions/upload-artifact@v4
      if: always()
      with:
        name: AppTests-${{ github.run_id }}
        path: build/AppTests.xcresult
        retention-days: 14
Enter fullscreen mode Exit fullscreen mode

3 details matter. pick the simulator dynamically, because the device list changes with runner images. set CODE_SIGNING_ALLOWED=NO so the test job needs no credentials. always upload the .xcresult — without local xcode it is the only way to see a failure.

5. tests that produce pictures

"does it look right" needs a channel. render swiftui views in tests with ImageRenderer, assert the geometry, attach the image:

let view = HeroView(item: fixture)
    .frame(width: 390)
    .environment(\.dynamicTypeSize, .accessibility5)
    .environment(\.colorScheme, .dark)

let renderer = ImageRenderer(content: view)
renderer.scale = 1
let image = try XCTUnwrap(renderer.uiImage)
XCTAssertEqual(image.size.height, 390 * 5 / 4, accuracy: 3, "poster keeps 4:5")
attach(image, name: "hero-dark-ax5")
Enter fullscreen mode Exit fullscreen mode

a CI step exports the attachments with xcrun xcresulttool export attachments and uploads them, so someone downloads a zip of PNGs and looks. render each screen in light and dark, at default and the largest accessibility text size, against a fixture environment pointed at an unreachable host. this is how I found a variable font whose named weights were silently falling back to the system font — no error anywhere, only the picture.

6. localhost HTTP in debug only, with 1 Info.plist

the simulator needs an app transport security exception for http://localhost and a release build must not carry one, but xcodegen generates 1 Info.plist per target. make the value a build setting:

NSAppTransportSecurity:
  NSExceptionDomains:
    localhost:
      NSExceptionAllowsInsecureHTTPLoads: $(ATS_ALLOW_LOCALHOST_HTTP)
      NSIncludesSubdomains: true
Enter fullscreen mode Exit fullscreen mode

with ATS_ALLOW_LOCALHOST_HTTP: YES in Debug and NO in Release. xcode materializes it as a real boolean, so release carries an exception that permits nothing. a check script asserts NSAllowsArbitraryLoads appears nowhere and every insecure-loads flag is Debug-gated.

7. deployment identity outside the repo

a tracked Release.xcconfig contains 1 line:

#include? "Release.local.xcconfig"
Enter fullscreen mode Exit fullscreen mode

the ? matters. xcodegen refuses to generate when a config file is missing, so pointing straight at a gitignored file breaks every fresh clone. with the optional include, an absent local file leaves DEVELOPMENT_TEAM, API_BASE_URL, and ASSOCIATED_DOMAIN undefined and the archive does not sign. do NOT commit a default; it ships unedited exactly once.

1 xcconfig trap: // inside a value starts a comment, so https://host truncates to https:. write https:/$()/host.

8. signing and uploading to testflight from the runner

you need 4 things from Apple: a paid developer account, an app store connect API key (key ID, issuer ID, .p8 private key), the app record in app store connect, and your team ID. store them as repository secrets.

the workflow, in order: fail early if a secret is missing, the bundle ID does not match the project, or a URL is not HTTPS. select a release xcode with sudo xcode-select --switch, because runners ship several and testflight rejects archives from betas. write Release.local.xcconfig from secrets and run xcodegen generate. install the .p8 under $RUNNER_TEMP, normalizing the CRLF and literal \n sequences password managers introduce, and verify it with openssl pkey -check without echoing it. then:

xcodebuild archive -project ios/App.xcodeproj -scheme App -configuration Release \
  -destination 'generic/platform=iOS' -archivePath build/App.xcarchive \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$ASC_KEY_PATH" -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
  DEVELOPMENT_TEAM="$APPLE_TEAM_ID" PRODUCT_BUNDLE_IDENTIFIER="$APPLE_BUNDLE_ID" \
  CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" CODE_SIGN_STYLE=Automatic
Enter fullscreen mode Exit fullscreen mode

before uploading, read Info.plist out of the archive with PlistBuddy and check the API URL and build number, then curl the API's health endpoint. that catches a testflight build pointing at localhost. export with method: app-store-connect and destination: upload, same key flags.

then poll. a short node script signs an ES256 JWT with the .p8, looks up the app by bundle ID, and polls builds?filter[version]=<run number> every 30 seconds for up to 15 minutes. uploads that fail processing are invisible otherwise. the github run number as CFBundleVersion gives every build a unique, increasing number with 0 bookkeeping.

9. reporting what could not run

criteria that need xcode cannot run on the linux VM. report them as blocked, not failed and not passed. failed makes the run permanently red and people stop reading red. passed is a trusted tick next to something nobody ran.

each automated criterion declares the toolchain it needs, and the verifier probes for it first:

- [ ] **AC-1.8** — Ten concurrent 401s trigger exactly one refresh call.
  - verify: auto
  - cmd: `xcodebuild test -scheme App -only-testing:AppTests/APIClientRefreshTests`
  - requires: xcode
Enter fullscreen mode Exit fullscreen mode

the xcode probe is xcodebuild -version exiting zero, which fails with only the command line tools. the swift probe is swiftc --version plus xcrun --show-sdk-path, enough for the macos type-check. a blocked criterion never counts as passed.

decide separately which criteria are genuinely human. "voiceover reads the hero in the right order" is. "no hard-coded colors remain in feature views" is a grep.

when it doesn't work

  • hosted green read as done. hosted screenshots got treated as device verification when no device had been touched. keep "hosted evidence" and "device evidence" as separate words in your tracking.
  • Apple's certificate limit blocked a run before upload. automatic signing from CI can create certificates only within the account's limits and cannot revoke anything. check Certificates in the developer portal before your first run.
  • the font fell back silently. a variable TTF's named weights did not register and the body rendered in the system font with no error. a check now fails the build if a requested face does not register.
  • shims drift, and preview bodies are unchecked. every new iOS-only API is a decision: shim it, exclude the file, or switch to a cross-platform API. prefer the third. anything only exercised in a #Preview stays unverified until a mac builds it.

the stack

xcodegen. swift 6 language mode with strict concurrency. view models that do not import swiftui. xctest plus ImageRenderer attachments. github macos-15 runners for building, testing, and signing. an app store connect API key with automatic signing. a node script with 0 dependencies for type-check staging, toolchain probing, and testflight polling, so it runs before npm install on any machine.

Top comments (1)

Some comments have been hidden by the post's author - find out more