App Distribution

App Distribution

App Distribution takes the builds your build machine makes to your testers: an iOS app's to TestFlight, an Android app's to Google Play's internal testing track. Your build machine makes an unsigned build with a build number the environment gives it and uploads it. Palbase signs it, hands it to Apple or Play with your own account, follows it until testers can install it, and gives it to the groups you name. No signing certificate, provisioning profile or keystore lives on the build machine, and no Apple ID signs in there.

It is the environment's stores module. Studio shows it under App Distribution, and the CLI's stores commands do the same: palbase stores:deliver, palbase stores:groups, …

How a build reaches testers

  1. Register the app and set it in the environment: Client Apps.
  2. Connect the store, once per environment: an App Store Connect API key (TestFlight), or a service account Palbase makes for you to invite in Play Console (Google Play).
  3. Link the app to its store (palbase stores:apps:link <app id>). An iOS app's bundle id is registered with Apple and its App Store Connect record looked up; an Android app gets the upload key Palbase signs its builds with.
  4. Make a package (palbase stores:packages:create). It answers the build number to build with and a token to upload the build.
  5. Build and upload it (palbase stores:packages:upload): the unsigned .ipa, or the .aab.
  6. Deliver it (palbase stores:deliver). It is signed, handed to Apple or Play, processed, and given to the testers.

Steps 1 to 3 are done once. A build machine runs 4 to 6 on every build.

From a build machine

A CI job with an organization's access token of the admin role as PALBASE_TOKEN:

set -euo pipefail
APP_ID=4f6c2d10-…   # palbase apps:get shop --json prints it

pkg=$(npx palbase stores:packages:create "$APP_ID" --command-id "$CI_RUN_ID" --notes "$GIT_SHA" \
  --entitlements-file entitlements.json -E main -y --json)
PACKAGE=$(echo "$pkg" | jq -r .id)
BUILD_NUMBER=$(echo "$pkg" | jq -r .build_number)
export PALBASE_UPLOAD_TOKEN=$(echo "$pkg" | jq -r .upload.token)

# Your build, unsigned, with that number: CFBundleVersion on iOS, versionCode on Android.
# TestFlight and Google Play below show one each.
./build-unsigned.sh "$BUILD_NUMBER"   # writes build/Shop.ipa

npx palbase stores:packages:upload "$PACKAGE" --file build/Shop.ipa -E main -y
npx palbase stores:deliver "$PACKAGE" --groups "Beta" --what-to-test "$(git log -1 --format=%s)" -E main -y
  • The command id names the request: the CI run's id. The same id again answers the same package, with a new upload token while it still waits for its build, so a job that is run again does not use up another build number.
  • The upload token is printed once, works for six hours and no longer than the access token, and only the newest one handed out works. PALBASE_UPLOAD_TOKEN keeps it out of the process list. The file goes straight to the environment's address with it; a second upload of the same build counts as uploaded.
  • -y confirms a change to the project's production environment, main, without asking. Every command that changes it asks first, and in a job that cannot answer it refuses (confirmation_needed).

Reading the module is a member's; making a package, uploading, delivering and every other change is an admin's. The few steps Apple cannot undo wait for a person: see Actions.

Packages

A package is one build of one app: the build number the environment handed out, the build once it arrived, and its deliveries.

FieldWhat it is
build_numberAbove every build number App Store Connect or Play has for the app, and every one the environment handed out for it. The build must say it: an iOS build's CFBundleVersion, an Android build's versionCode.
statusawaiting_upload until the build arrives, then uploaded
versionWhat the build says it is, once it arrived: CFBundleShortVersionString, or the Android versionName
bundlesAn iOS build's app and each extension, App Clip and watch app in it, by bundle id
sha256, sizeThe uploaded file's
notesWhat --notes kept with it: a commit, a branch
deliveriesWhere it went, and where each delivery is now

A package is made for a linked app (404 app_not_linked otherwise), and an iOS app's needs its App Store Connect record (409 app_record_missing): the build number is read from App Store Connect, or from Play once the environment is connected to it.

An upload is refused, and the package keeps waiting for the right file, when the build is not the app's (another bundle id or package, wrong_bundle) or says another build number (wrong_build_number). An .ipa is 2 GiB at most. One upload of a package runs at a time, and two of an environment's at once; the CLI waits and sends again when the module says it is busy. An upload ends within an hour and keeps up 256 KiB a second on average after its first minute.

A build is kept 90 days, as long as TestFlight keeps one. palbase stores:packages lists the newest packages (--app for one app's), and palbase stores:packages:get <package id> shows one.

Deliveries

palbase stores:deliver <package id> sends a package to its app's channel: testflight for an iOS app, play_internal for an Android app. It answers at once; palbase stores:deliveries <package id> and Studio follow it.

StatusWhat is happening
queuedWaiting its turn: an environment's builds are signed and handed over one at a time
signingPalbase signs the build
uploadingPalbase hands it to App Store Connect, or to Play
processingApple processes it
complianceApple waits for the build's export compliance answer
readyTesters can install it: TestFlight gave it to the groups named, or Play took it on the internal testing track
failedIt stopped. error_code says why and error what to do

Sending a package again restarts a delivery that failed and changes nothing about one under way. An environment has 10 deliveries under way at most (429 too_many_deliveries).

What stops a delivery, by error_code, is on TestFlight and Google Play. Beside those:

error_codeWhat to do
build_changedThe kept build is not the one uploaded: upload it again as a new package.
build_goneThe build is no longer kept (90 days): upload it again as a new package.
step_timed_outSigning or handing it over took more than 45 minutes: send it again.
gave_upSeveral tries in a row failed; error names the reference the cause is kept under.

Actions

What changes something at Apple for good is planned first and runs only when a person confirms it: sending a build to Beta App Review, answering a build's export compliance question, expiring a build, and on the App Store writing an app's page, sending its version to App Review, withdrawing it, releasing it and changing its phased release. A plan holds a summary of exactly what it does and a hash of it.

palbase stores:beta-review <package id>        # plans it, prints its id and hash
palbase stores:actions --status planned
palbase stores:actions:confirm <action id> --hash <hash>
palbase stores:actions:decline <action id>

A confirmation names the hash of the plan that was shown, runs it once, and only within a day of planning. It is a person's: a signed-in CLI or Studio's App Distribution › Actions, where planned actions wait under Waiting for you. An access token, an environment token or the secret key alone is refused (403 person_required), so a build machine plans and a person confirms. The CLI shows what the action does and asks before it runs, in every environment; -y confirms without asking. Declining needs no person.

In Studio

App Distribution, under Release in an environment:

TabWhat it holds
OverviewThe Apple account and the Google Play connection (connect, check again, disconnect), the linked apps (Link an app), and packages made per week
PackagesEvery package, newest first. A package's page sends it (Send to TestFlight, Send to internal testing, Send again), follows its deliveries, gives it to a group (Give to a group), changes what testers read (Edit what to test), plans Send to Beta App Review, Answer export compliance and Expire the build, and downloads an Android package's Signed bundle
GroupsAn iOS app's groups of testers: make, rename, delete, set their testers
ActionsWhat waits for a person's yes, and what was done, declined or failed
TestFlightAn iOS app's test information for Beta App Review, and what testers sent back
HealthAn iOS app's App Store Connect webhook (Ping, Make again), testers' crashes and screenshots, and the App Store's crash data; an Android app's Play vitals (App Health)
App StoreAn iOS app's App Store page (each language's text, its categories and screenshots), planned and confirmed, and the version App Review gets: what is missing, Send to App Review, Withdraw from App Review, Release and the phased release

Events

Every change emits a stores.* event to the environment's webhooks and to a backend's @On handlers:

EventWhen
stores.apple.changedThe Apple account was connected, checked (and Apple's answer changed) or disconnected. Never the key.
stores.google.changedThe Play connection was made, checked (and Play's answer changed) or disconnected
stores.app.changedAn app was linked to its store, or its App Store Connect record made or removed with the Apple ID
stores.package.changedA package was made, or its build arrived
stores.delivery.requestedA delivery was sent, or sent again
stores.delivery.changedA delivery moved: queued, signing, uploading, processing, compliance, ready or failed
stores.group.changedA group was made, renamed or deleted, its testers set, or a build given to it
stores.action.changedAn action was planned, done, declined or failed
stores.version.changedAn app's App Store version came to a new state: sent to App Review, in review, approved, rejected, withdrawn, released. source says who told it: apple_webhook, poll or action
stores.apple.session.changedAn Apple ID was signed in, or signed out. Never the session.
stores.apple.session.expiredApple ended the Apple ID's session: sign in again
stores.review.messageApp Review wrote in the app's Resolution Center: the text, the guidelines it names, its attachments (with an Apple ID signed in)

What the stores tell of an app — its App Store Connect webhook, its build uploads, Beta App Review, testers' feedback, its in-app purchases' review, its Play releases and its crash data — is on App Health.

// src/modules/releases/release-events.ts
import { Log, On, type PlatformEvent } from "palbase/backend";

export class ReleaseEvents {
  @On("stores.delivery.changed")
  moved(event: PlatformEvent<"stores.delivery.changed">): void {
    const { package_id, channel, status, error_code, error } = event.data;
    if (status === "failed") Log.warn("a delivery stopped", { package_id, channel, error_code, error });
    if (status === "compliance") Log.info("a build waits for its export compliance answer", { package_id });
  }
}
// src/modules/releases/releases.module.ts
import { Module } from "palbase/backend";
import { ReleaseEvents } from "./release-events";

@Module({ providers: [ReleaseEvents] })
export class ReleasesModule {}

From a server

The module's API is the environment's, at its address under /stores, with the secret key: createServer(…).stores in any server, Palbase.stores in a palbase/backend app, the same calls the CLI makes (listPackages, getPackage, deliver, listDeliveries, …). Confirming an action is the one call it cannot make: that is a person's.