TestFlight from Linux (no Mac, no Xcode, no Transporter) #
A pure-Linux pipeline to ship the iOS app to TestFlight from this box:
xtool dev build -c release --ipa → zsign (distribution re-sign) → App Store Connect API upload
Everything runs on Linux. The only Apple tool involved is the App Store
Connect REST API (POST /v1/buildUploads), which is officially
cross-platform — no iTMSTransporter, no altool, no container.
⭐ Recommended path: GitHub Actions macOS runner (.github/workflows/testflight.yml) #
The pure-Linux pipeline below gets a build all the way through App Store Connect
processing except for one wall: the app-icon asset catalog. Apple requires
a compiled Assets.car + CFBundleIconName for any iOS-11+-SDK build (error
90713), and compiling one needs Apple's actool, which does not exist on Linux.
Because the repo is public, GitHub gives free, unlimited macOS runners
with full Xcode (actool, codesign, iOS 26 SDK). So the real pipeline lives in
.github/workflows/testflight.yml and does the whole release on macos-26:
xtool build → actool (icons) → prepare_bundle.py → codesign → asc.py upload
One-time setup
- Push this repo to GitHub (
git@github.com:joelghill/soapstone-mobile.git). - Create the signing assets locally if you haven't (
asc.py cert+profile). scripts/testflight/github-secrets.shprints the 6 required secrets — add them under Settings → Secrets and variables → Actions:ASC_KEY_ID,ASC_ISSUER_ID,ASC_API_KEY_P8,DIST_P12_BASE64,P12_PASSWORD,APPSTORE_PROFILE_BASE64.- Actions → TestFlight Release → Run workflow. Build number auto-defaults to
a timestamp (
date +%Y%m%d%H%M), so it never collides.
Why not zsign? #
zsign (used by sign.sh) re-signs fine for sideloading, but App Store
Connect rejects its signatures with error 90034 "not signed using an Apple
submission certificate" — even with a valid Apple Distribution cert and the full
WWDR chain embedded. App Store submission needs a signature with a secure
timestamp and proper CMS. Use codesign (CI) or rcodesign
(apple-codesign, installable on Linux) instead. sign.sh/zsign is kept only for
dev/sideload use.
Gotchas this pipeline handles (learned the hard way) #
- Bundle id — the App Store app record (
6784071446) + the only registered bundle id are xtool's auto idXTL-6L669QTV3D.social.soapstone.app, not the plainsocial.soapstone.appthe build stamps.prepare_bundle.pyrewrites it. - SDK stamp — xtool stamps
LC_BUILD_VERSION sdk=18.0; ASC needs ≥ 26 (error 90725). A native Xcode-26 build fixes it;prepare_bundle.pypatches it as a safety net. asc.pyupload — chunkPUTs must NOT carry the APIAuthorizationheader (Apple's object storage 400s otherwise). Fixed inasc.py..p12for rcodesign — must be legacy PBES1-SHA1-3DES (notcryptography's AES/PBES2) and leaf-only; pass the WWDR intermediate via--certificate-der-file.
Files #
| File | What it does |
|---|---|
asc.py |
App Store Connect API client: cert, profile, upload |
prepare_bundle.py |
complete a raw xtool .ipa (icons, id, build number, DT keys, SDK) — used by CI and Linux |
github-secrets.sh |
print the 6 GitHub Actions secrets from your local signing assets |
sign.sh |
re-sign with zsign — sideload/dev only (rejected by App Store, see above) |
deploy.sh |
Linux build → sign → upload, one shot (needs rcodesign + a compiled Assets.car) |
config.example.env |
copy to config.env, fill in, source it |
../../.github/workflows/testflight.yml |
the recommended full release pipeline (macOS runner) |
Tooling already present on this machine: xtool, zsign (~/.local/bin/zsign),
python3 with jwt / cryptography / requests.
One-time prerequisites (only you can do these) #
- Paid Apple Developer Program — confirmed active on team
6L669QTV3D(xtool ds teamsshows Apple Developer Program (iOS)). TestFlight is impossible without it. - App Store Connect API key — App Store Connect → Users and Access →
Integrations → App Store Connect API → generate a Team key with Admin
access. You get a Key ID, an Issuer ID, and a one-time
AuthKey_XXXX.p8download. Keep the.p8safe (it can't be re-downloaded). - App record — create the app for bundle id
social.soapstone.appin App Store Connect (My Apps → +). Needed so the build has somewhere to land. - The bundle id
social.soapstone.appmust be registered under your team (Certificates, Identifiers & Profiles → Identifiers). It almost certainly is already, since xtool installs builds with it.
Setup #
cp scripts/testflight/config.example.env scripts/testflight/config.env
# edit config.env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8, P12_PASSWORD
source scripts/testflight/config.env
Create signing assets (once, or when the cert expires/rotates) #
# Apple Distribution certificate + .p12 (writes to scripts/testflight/secrets/)
python3 scripts/testflight/asc.py cert --password "$P12_PASSWORD"
# App Store provisioning profile (.mobileprovision)
python3 scripts/testflight/asc.py profile
cert generates the private key locally, has Apple issue the certificate, and
bundles both into secrets/distribution.p12. profile creates an
IOS_APP_STORE profile bound to the bundle id + that certificate. Point
DIST_P12 / APPSTORE_PROFILE in config.env at the outputs (defaults already do).
Distribution certs are limited (max 2–3 per team). If you already have one, skip
cert, export its.p12some other way, and just runprofile --cert-id <ID>(list ids with the API or the portal).
Deploy #
Bump the build number first (App Store Connect rejects a re-used
CFBundleVersion). xtool injects the version; if you need to override it during
signing, zsign -r <version> / -n <name> can do it — otherwise edit the source
of truth and rebuild.
source scripts/testflight/config.env
scripts/testflight/deploy.sh
That runs all three stages and polls until App Store Connect reports COMPLETE.
The build then shows up under App Store Connect → TestFlight after Apple
finishes processing (a few minutes). Export compliance is already declared
(ITSAppUsesNonExemptEncryption=false in Info.plist), so it won't block.
Or run stages individually #
xtool dev build -c release --ipa
scripts/testflight/sign.sh xtool/soapstone_mobile.ipa /tmp/signed.ipa
python3 scripts/testflight/asc.py upload --ipa /tmp/signed.ipa
Known caveat — the commit checksum #
The upload does: create build upload → reserve file → PUT each chunk Apple
hands back → commit (PATCH …/buildUploadFiles/{id} with uploaded:true). Apple's
docs don't fully specify the sourceFileChecksums field, so asc.py defaults to
sending the file MD5 (plus a composite MD5 for multipart). If the commit is
rejected on checksum grounds, try:
python3 scripts/testflight/asc.py upload --ipa signed.ipa --checksum-algorithm SHA_256
python3 scripts/testflight/asc.py upload --ipa signed.ipa --skip-checksums
This is the one step that hasn't been tested against the live API yet (it needs your key + app record). Everything upstream — build, sign, JWT auth, the request shapes — is verified.
Security #
secrets/, config.env, and all .p8/.p12/.mobileprovision/.ipa files
are gitignored. Never commit them.