Mobile client for SoapStone
README.md

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.


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

  1. Push this repo to GitHub (git@github.com:joelghill/soapstone-mobile.git).
  2. Create the signing assets locally if you haven't (asc.py cert + profile).
  3. scripts/testflight/github-secrets.sh prints 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.
  4. 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 id XTL-6L669QTV3D.social.soapstone.app, not the plain social.soapstone.app the build stamps. prepare_bundle.py rewrites 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.py patches it as a safety net.
  • asc.py upload — chunk PUTs must NOT carry the API Authorization header (Apple's object storage 400s otherwise). Fixed in asc.py.
  • .p12 for rcodesign — must be legacy PBES1-SHA1-3DES (not cryptography'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) #

  1. Paid Apple Developer Program — confirmed active on team 6L669QTV3D (xtool ds teams shows Apple Developer Program (iOS)). TestFlight is impossible without it.
  2. 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.p8 download. Keep the .p8 safe (it can't be re-downloaded).
  3. App record — create the app for bundle id social.soapstone.app in App Store Connect (My Apps → +). Needed so the build has somewhere to land.
  4. The bundle id social.soapstone.app must 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 .p12 some other way, and just run profile --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.