0x0011f00 · SYS/DJY89 · REV.03 · bootfonts · Archivo · Martian Mono · loadinglight form · detecting· renderedrender path · app router · nominal
DJY89
000025050075100
DJY89
← Resources indexSeptember 3, 2026 · 8 min read
0x0011f61 · SEC/ARTICLE/SHIPPING-IOS-HEADLESS · REV.03

Shipping to TestFlight Without Opening Xcode

A Python terminal game shipped to TestFlight as a native iOS app with the Xcode GUI never opened once: Windows editing, a Mac mini over SSH, 175 tests green.

SwiftiOSDifferential TestingBuild AutomationApp Store Connect API
Article contents +

You can ship a signed, reviewed, test-covered native iOS build to TestFlight without ever opening Xcode. I did it from a Windows machine driving a Mac mini over SSH: XcodeGen for the project file, xcodebuild for the archive, and the App Store Connect REST API for nearly everything Apple normally makes you click. Exactly one step in the pipeline refused to automate, and it is not the one you would guess.

175
Tests green
163 EngineKit unit, 12 XCUITest UI, clean clone
11
Defects found by AI review
Read-only pass at max reasoning
0
Times the Xcode GUI opened
Windows editor, Mac mini over SSH
VALID
Build v1 on TestFlight
Processed and installed
One port, one headless pipeline, one TestFlight build

The thing being ported is Z SHELL, a terminal-native story game that teaches Unix from zero inside a fully simulated shell. It was already finished and playable in pure Python, standard library only: 15 missions, 45 objectives, 47 simulated commands. The iOS version is a SwiftUI app on top of EngineKit, a pure-Foundation Swift package holding the shell, the virtual filesystem, the campaign, the grading, and save/load, so the entire engine runs and tests with no simulator attached.

That split is what made the rest of this possible. If the engine needs a screen, none of what follows works.

A Port Needs an Oracle, Not More Tests

The failure mode of any rewrite is that the tests are written by the person doing the rewriting. You assert what you believe the behaviour to be, in the new language, using the new mental model. When the port is subtly wrong, the tests agree with it.

So the Swift port is not graded against my beliefs. It is graded against the Python.

A differential harness generates committed goldens by running the original Python game, then replays the identical inputs through EngineKit and diffs the output. The rule is deliberately blunt: any divergence between Swift and Python is a bug in the Swift. There is no arguing with the harness, because the harness is not expressing an opinion. It is quoting the thing that already worked.

Python's Edge Cases Were the Actual Spec

The harness earned its cost on details nobody would think to assert.

Python's shlex raises on a trailing backslash rather than passing it through, so a player typing ls foo\ gets a parse error, not a lookup. And posixpath preserves exactly two leading slashes, so //etc normalises to //etc while ///etc collapses to /etc. Both behaviours are correct POSIX trivia. Both are invisible unless you go looking. A hand-written Swift test would have rubber-stamped whatever the Swift happened to do, and the shell would have felt fractionally wrong forever in a way no bug report ever names.

Once the harness was green, the campaign itself became a test: the full 15-mission run completes under two different random seeds, driven entirely through the public submit() API, with no UI in the loop.

A Read-Only Reviewer Found 11 Things the Tests Could Not

Tests confirm the behaviours you thought of. For the ones I did not, I handed the whole port diff to an OpenAI Codex model running at its maximum reasoning setting, read-only, with no ability to change anything.

It came back with 11 real defects: data loss in save and restore, an out-of-range index crash, and several places where an instrument's on-screen fidelity had drifted from the Python. I verified every finding against the source before touching it, and every one of them was fixed. The reviewer also listed, separately, what it had checked and cleared, which is the half of the output that makes the other half usable. A list of complaints with no stated scope is just noise with confidence attached.

A test suite and an adversarial reader are not substitutes for each other. The harness proved the port matched. The reviewer found the places where matching was not enough.

The Project File Has to Be Text or the Headless Box Loses

The build ran on a Mac mini (macOS 26.6, Xcode 26.6) reached only over SSH. Editing and orchestration stayed on Windows 11. The Xcode GUI was never opened, not once, not even to check something.

Two decisions carried that:

The project is XcodeGen YAML, with no checked-in .pbxproj. A pbxproj is a file format that assumes a GUI is maintaining it. Adding a test target or a resource by hand-editing one over SSH is possible in the way surgery with a butter knife is possible. Generating it from YAML keeps the project definition a text file I can edit from anywhere.

The app icon is generated by a Python script. Pillow draws an amber block cursor on the game's ground colour and writes every required size. No design tool, no dragging assets into a catalogue, and the icon regenerates from source like everything else in the repo.

Naming the Simulator Was a Correctness Decision

UI tests ran on the iPhone 15 simulator, selected by name.

That is not fussiness. The design's canonical size is 393 by 852 points, and one of Z SHELL's hard layout rules is that the HUD must never truncate at that width. Xcode's default current-generation simulator is 9 points wider. Every test would have passed. The rule they exist to enforce would have gone unenforced, on the exact device class it was written for.

Defaults are a silent choice about what you are testing. On a headless box, where nobody is going to glance at the simulator window and notice it looks roomy, naming the device is the only thing that makes the rule mean anything.

App Manager Can Upload but It Cannot Sign

App Store Connect was stood up from scratch through its REST API, authenticated with a JWT signed by an ES256 .p8 key: register the bundle ID net.djy89.zshell, resolve the Team ID, archive, export, upload.

The export died with Cloud signing permission error.

The message reads like a certificate problem. It is a role problem. An App Store Connect API key with the App Manager role has enough authority to upload a build but not enough to perform distribution cloud-signing. Nothing about the key was expired or malformed. It simply was not allowed to sign. A new key issued with the Admin role went through on the first try.

If you are automating distribution, provision the Admin key up front. App Manager gets you all the way to the last step and then stops.

The App Record Is the Only Thing You Still Have to Click

The second wall does not move at all.

You cannot create an app record through the public API. The apps resource answers a creation request with 403 FORBIDDEN, stating plainly that it does not allow CREATE. The alternatives are the web UI, or fastlane's Apple ID session, which needs interactive two-factor auth and therefore needs a human anyway.

Everything else automates:

StepAutomatable via the APINotes
Register bundle IDYesnet.djy89.zshell, one call
Resolve Team IDYesRead back from the account
Create the app recordNo403 FORBIDDEN, web UI only
Archive and cloud-signYesxcodebuild archive, Admin key required
Export a signed IPAYesxcodebuild -exportArchive
Upload to TestFlightYesxcrun altool
Clear export complianceYesSet on the build after processing
Create an internal test groupYesGroup, attach build, invite tester
Ship a build to TestFlightAll but one stepOne browser visit, once per app, ever

That is the honest shape of it. The manual island is a single form you fill in once in an app's lifetime. Every build after it is a script.

What Actually Shipped

Build v1 processed to VALID and installed on a phone through TestFlight. The machine it came from never displayed a pixel of Xcode.

None of the individual pieces here are exotic. XcodeGen is well known, xcodebuild has always been scriptable, and the App Store Connect API is documented. What is worth writing down is that the whole chain holds end to end, and exactly where it snaps: one role that looks sufficient and is not, and one resource that is simply closed.

Key takeaways
  • Port against an oracle, not against your beliefs. Goldens generated from the original make any Swift/Python divergence a bug by definition, and they caught shlex and posixpath edge cases a hand-written test would have rubber-stamped.
  • Keep the engine free of the UI. A pure-Foundation Swift package let 163 unit tests and a full 15-mission campaign run headless, which is what made an SSH-only pipeline possible at all.
  • Name your simulator. The default current-generation device is 9 points wider than the 393pt design target, so a truncation rule would have passed while going untested.
  • Provision the Admin API key first. App Manager can upload a build but cannot cloud-sign it, and the failure reads like a certificate error.
  • The App Store Connect app record is the one thing the API will not create. Bundle IDs, signing, compliance, groups, and testers all automate; the record costs one browser visit per app, forever.
Transmit
LinkedInX
Questions · direct answers

FAQ.

01
Can you ship a native iOS app to TestFlight without opening Xcode?
Yes. Define the project as XcodeGen YAML instead of a checked-in pbxproj, drive xcodebuild archive and exportArchive over SSH on a Mac, upload the signed IPA with xcrun altool, and use the App Store Connect REST API for bundle IDs, compliance, testing groups, and testers. Only the app record itself needs a browser.
02
How do you test a Swift port so it actually matches the original?
Make the original the oracle. A differential harness generates committed goldens from the Python implementation, then replays the same inputs through the Swift engine and diffs the results. Any divergence is a Swift bug by definition, so the port cannot quietly redefine correctness the way a hand-written Swift test can.
03
Why does App Store Connect cloud signing fail with an App Manager API key?
Because the App Manager role can upload builds but cannot perform distribution cloud-signing. The export step dies with a cloud signing permission error that reads like a certificate problem and is actually a role problem. Issuing a new App Store Connect API key with the Admin role fixes it outright.
04
Can you create an App Store Connect app record through the API?
No. The public apps resource rejects creation with 403 FORBIDDEN, reporting that the request does not allow CREATE. The record has to be made in the web UI, or through a fastlane Apple ID session that needs interactive two-factor auth. Everything after the record, signing included, automates.
0x0011f62 · SEC/ARTICLE · RELATED ENTRIES