iOS Releases & TestFlight
How iOS builds reach TestFlight: Hands uploads server-side with the stored ASC credential.
How a Raft iOS build travels from CI to TestFlight. The key fact: Hands uploads to Apple server-side — the App Store Connect credential lives encrypted in Hands, and the .p8 key never leaves it. CI does not need Apple upload credentials, and nobody runs altool.
The flow
iOS Release workflow Hands Apple
──────────────────── ───── ─────
build + sign IPA ──────────▶ build + assets in R2
(publish_hands=true)
testflight-upload ───────────▶ Build Upload API
(streams IPA from R2) PROCESSING → COMPLETE
testflight-publish ───────────▶ VALID build
groups + What to Test internal testing, or
beta review + notify external Beta Review
- Build + sign + publish — dispatch the
iOS Releaseworkflow (botiverse/mobile) withpublish_hands=true. It builds, signs with the distribution certificate, and uploads the IPA + dSYM to Hands as a build (usually with a draft release). - Upload to TestFlight — trigger the server-side upload for that build:
`` POST /api/apps/{app_id}/builds/{build_id}/testflight-upload ``
Hands streams the IPA from R2 straight to Apple's Build Upload API (create → register file → part PUTs → commit) and returns the initial state. Console: the build row's TestFlight action.
- Poll upload processing —
`` GET /api/apps/{app_id}/testflight-uploads/{build_upload_id} ``
Read the nested state.state field: AWAITING_UPLOAD → PROCESSING → COMPLETE | FAILED; the same object retains Apple's errors, warnings, and infos. COMPLETE means Apple accepted the upload transaction; wait until the corresponding build resource reports processingState=VALID before distribution.
- List beta groups — use the processed Hands build to resolve the exact App Store Connect app and list stable group ids:
`` GET /api/apps/{app_id}/builds/{build_id}/testflight-groups ``
- Publish to TestFlight — assign the exact processed build to groups and write localized What to Test metadata:
`` POST /api/apps/{app_id}/builds/{build_id}/testflight-publish { "distribution": "internal", "group_ids": ["<asc-beta-group-id>"], "what_to_test": { "en-US": "Verify login and Activity.", "zh-Hans": "验证登录和活动页。" }, "notify_testers": false } ``
Internal mode adds only internal groups. External mode adds only external groups, submits betaAppReviewSubmissions, and sets buildBetaDetails.autoNotifyEnabled. When an external build is already approved, notify_testers=true creates the official buildBetaNotifications resource only when automatic notification was not already enabled.
- Refresh distribution state —
`` GET /api/apps/{app_id}/builds/{build_id}/testflight-publish?distribution=external ``
The response keeps Apple's raw processing, expiry, internal/external build, Beta App Review, auto-notify, assigned-group, and localization state. It distinguishes waiting_for_review, in_review, approved_not_notified, testing, rejected, expired, and processing failures.
- Expire an exact beta build when it must leave testing — after explicit human authorization, an app admin supplies all three independent confirmations from the status response:
`` POST /api/apps/{app_id}/builds/{build_id}/testflight-expire { "asc_build_id": "<exact-asc-build-id>", "confirm_version": "1.0.0", "confirm_build_number": "1000006" } ``
Hands resolves the immutable build tuple again, refuses any ASC id or version/build mismatch, sets only the ASC Build resource's expired flag, and immediately reads the same build back. Before the Apple mutation it stores a redacted operation intent containing the actor and exact build coordinate; PATCH/readback success or failure terminalizes that receipt. Retrying an already-expired exact build is a 200 no-op with changed=false and links to any prior PATCH-confirmed operation, so a lost readback or audit response cannot erase who triggered the mutation. Expiration removes TestFlight availability; Apple keeps the build record for audit/history. Responses include operation_id for supported inspection.
These endpoints and the matching CLI/integration actions are TestFlight-only. They never activate a Hands release and never create, submit, or release an App Store production version.
Publication receipts and common pitfalls
Treat upload, review, tester notification, and App Store production as four different boundaries:
- Build Upload
COMPLETEand ASC buildVALIDprove only that Apple accepted and processed the binary. - Group assignment, What to Test,
BETA_APPROVED, orapproved_not_notifiedprove eligibility, not tester notification. auto_notify_enabledis a scheduling-policy field, not an event log.falsecan appear before or after a manual notification and cannot prove that notification did not happen.- TestFlight tester notification never activates a Hands release and never publishes an App Store production version.
For an external publish that must notify testers, preserve two independent receipts:
- The mutation response from
testflight-publishmust havenotification=sentornotification=already_sent. - A fresh, separate
testflight-statusread must showexternal_build_state=IN_BETA_TESTINGfor the exact ASC build.
Do not add --wait to the receipt-producing CLI call. The current testflight-publish --wait --json implementation prints the final polled GET status instead of the original POST response; GET status does not contain the POST notification or operation_id. Use the publish command without --wait, save its JSON, and poll separately with testflight-status.
The notification enum is deliberate:
| Value | Meaning | Notification complete? |
|---|---|---|
not_requested | No notification requested | No |
scheduled | Auto-notify is pending review/testing | No |
sent | Manual notification operation succeeded | Yes, with fresh IN_BETA_TESTING |
already_sent | Idempotent replay found notification already complete | Yes, with fresh IN_BETA_TESTING |
When the first mutation returns scheduled, wait until the exact build reaches IN_BETA_TESTING, then replay the same publish without --wait. Preserve the expected already_sent receipt and fresh-read the exact status once more. If either receipt is missing after a bounded recheck, report notification as unverified rather than calling the build published or sent.
One-time setup (app admin)
Store the App Store Connect API credential in Hands: console → App → Settings → TestFlight (PUT /api/apps/{app_id}/asc-credentials). Generate the key in App Store Connect → Users and Access → Integrations (App Manager role); you need the Key ID, Issuer ID, and the .p8 file. The credential is encrypted at rest and can be verified without exposing it (POST .../asc-credentials/verify). The same credential powers the App Store review-state surface (GET /api/apps/{app_id}/appstore-review). Hands is the credential's only home — CI never needs a copy, so rotation stays single-source.
Hands roles are intentionally split: uploading to Apple requires app admin, expiring an exact beta build requires app admin, distribution requires app publisher, and status/group reads require app viewer. Apple's own role boundary still applies to the stored key: external testing requires Account Holder, Admin, or App Manager; internal testing also permits Developer or Marketing.
Before first external testing, App Store Connect must have the required Beta App Description, feedback email, contact information, and export-compliance answers. Hands fails closed and returns Apple's actionable state/error if those prerequisites are missing. Apple allows only one build of a version in Beta App Review at a time and up to six submitted builds in a 24-hour period.
Versioning rules
- The marketing version (e.g.
1.0.0) may repeat across uploads. - The build number (
versionCode, e.g.1000004) must be unique and ascending for that marketing version — Apple rejects reused build numbers. Hands build history is the quick way to see the last used code. - TestFlight access expires 90 days after upload.
- External distribution is fail-closed: Apple must explicitly return
build_audience_type=APP_STORE_ELIGIBLE.INTERNAL_ONLY, missing, or future unknown audience values are rejected before any localization, group, review, or notification mutation.
CLI and Raft actions
CLI:
hands builds testflight-groups <app> <hands-build-id>
hands builds testflight-publish <app> <hands-build-id> \
--distribution external \
--group-id <asc-beta-group-id> \
--what-to-test en-US="Verify the release candidate." \
--notify-testers \
--json > testflight-publish-receipt.json
hands builds testflight-status <app> <hands-build-id> \
--distribution external \
--json
Raft integration actions:
get-testflight-beta-app-descriptionupdate-testflight-beta-app-descriptionupload-testflight-buildget-testflight-upload-statuslist-testflight-groupsexpire-testflight-buildpublish-testflight-buildget-testflight-publish-status
Agents pass Hands app/build ids and stable beta group ids. The integration never returns the .p8 credential.
Beta App Description is app-level invitation copy and uses betaAppLocalizations.description. It is not the build-level What to Test field (betaBuildLocalizations.whatsNew). The dedicated update action preserves unsupplied locales and succeeds only after exact Apple readback.