Builds and signing
Ploa builds Expo, Flutter, React Native, Capacitor and native Swift or Kotlin apps, each with its own tools, signs every build with keys that never leave its server, and sends it to TestFlight and Google Play internal testing. Your agent starts each build and follows it until testers have it. Apps made any other way ship as they're built.
Run preflight, fix what the stores would reject, then build iOS and Android and send them to testers.
Build Android on GitHub from what's pushed to the release branch.
Is the build on TestFlight yet? If it failed, find out why and fix it.
Stop the build that's running.
The tools behind these requests: Every MCP tool › Builds. The Android upload key's tools are under Start and setup, and builds on GitHub under GitHub.
Who does what
| Who | What |
|---|---|
| Your agent | Runs preflight and fixes what it finds (ploa_preflight); starts the build where you chose (ploa_release_start); follows it (ploa_release_status, ploa_runs); sends the build files git doesn't have (ploa_build_files_push); sets up builds on GitHub (ploa_github_setup); stops a build only after you say yes (ploa_release_stop). |
| Ploa | Picks the next free build number, signs the build, uploads it (or opens the upload for the build machine), follows Apple's processing, puts it on TestFlight or internal testing, makes a new app's Android upload key, and tells you when testers have it or it failed. |
| You | Choose where builds run (minutes on GitHub, Xcode Cloud hours or your Google Cloud's bill are yours); approve build files and the link to a GitHub repository; give Ploa an existing Android upload key once; and do the few steps Apple leaves to you (an Xcode Cloud product made in Xcode, a capability Apple's API can't turn on). |
A build only ever goes to testers. App Review and production are separate steps you approve; see App Review and release.
Before a build: preflight
Preflight checks the project for what App Store Connect and Google Play would reject, before any time goes into a build: version and build numbers against the stores, bundle IDs, the icon, iOS purpose strings, the privacy manifest, export compliance, Play's target SDK, sensitive permissions, and secrets committed to git. Each finding comes with its fix, and your agent runs it before every build. It reads Expo projects; for Flutter, React Native without Expo, Capacitor, Gradle and Xcode apps it says it doesn't check them yet, and the build goes ahead.
Where builds run
| Where | Platforms | Good to know |
|---|---|---|
| This computer | iOS (a Mac with Xcode), Android | The default for an agent on your computer. Builds what's on disk, local changes too. Never counted. |
| GitHub Actions | Android on GitHub's Linux; iOS on your own Mac as a self-hosted runner, or on GitHub's Macs where an owner allowed them | Builds what's pushed, with the workflow Ploa writes for the repository. Minutes count on your GitHub account, not Ploa's: free on a public repository; on a private one a Mac minute counts as ten (an iOS build is about 150 to 200). The only way for an agent on the web. See GitHub. |
| Xcode Cloud | iOS | Apple builds and signs it from what's pushed (main unless you name a branch), and Ploa follows it to TestFlight; counts against your Xcode Cloud hours with Apple. |
| Your Google Cloud | Android | Cloud Build in your own project, from what's pushed; Google bills the minutes to that project, never Ploa. Ploa still signs the bundle: no key goes to Google. |
Your agent asks where to build, or picks for you: where: "auto" builds on GitHub when that platform is ready there, and on this computer otherwise, and says which and why. Everywhere but this computer, a build takes what's committed and pushed, not local changes.
Setting up Xcode Cloud
Apple's API can't create the app's Xcode Cloud product or connect the repository, so that first step is in Xcode on a Mac (the cloud button › Get Started). Everything after it Ploa does: ploa xcode-cloud in the app's folder gets the repository ready (for an Expo app, the script that makes ios/ after Xcode Cloud clones it) and says what's left, and Ploa prepares the workflow so it archives for TestFlight and starts only when Ploa starts it. The signing key stays with Apple.
Setting up your Google Cloud
An owner sets the project up once in Settings › Builds, which says what to run in Cloud Shell. The free tier's machine is free for 2,500 minutes a month but slow for Gradle (half an hour or more a build); a bigger machine you pick costs per minute from the first one, and your agent says which before it builds.
iOS: signing without the key on your Mac
On your Mac, the build runs in Xcode and is signed with Ploa's distribution key, which stays on Ploa: your Mac sends only the parts that need a signature, and Ploa signs them. Nothing to set up in Xcode, no certificate in your keychain. Ploa makes the certificate with Apple when you connect the App Store, and turns on the capabilities the app's entitlements need (push, App Groups) at Apple. A capability Apple's API can't turn on is your step, before the first build that uses it: the setup guide gives you the link to the bundle ID's page at Apple.
The upload. By default the Mac uploads the build straight to App Store Connect, with one-time links Ploa asks Apple for: the file never reaches Ploa, and the App Store key stays on Ploa's server. In App settings › The app › App Store, How iOS builds are uploaded can be set to Through Ploa instead: the Mac sends the file to Ploa, which uploads it. Apple's processing then takes up to half an hour.
The build number is the next free one unless you or your agent pass one. With --sign keychain, the key goes into a throwaway keychain on the Mac while Xcode and codesign sign.
Android: the upload key in the vault
Every Android build is signed with the app's upload key before it goes to Google Play. Ploa keeps that key in its vault, so no keystore sits on your computer or in CI.
The bundle goes from the machine that built it straight to Google Play. That machine prepares the bundle's signature file, Ploa signs just that file with the upload key, and the signed bundle is uploaded to Google through an upload session Ploa opens with its own access to Google Play. The session lets that machine upload this one bundle and nothing else, so neither the key nor Ploa's access to Google Play ever leaves Ploa. Ploa then checks that Google has the bundle, with the right version code, and puts it on internal testing. A large bundle travels once, not twice.
- A new app (Play has never seen a build): before the first build, Ploa makes a new key right in the vault (RSA 2048, valid 30 years), so no one holds a copy. Play takes the first build's key as the app's upload key. Nothing for you to do.
- An app Play already knows (built with EAS or Android Studio): give Ploa the existing keystore (
.keystore,.jksor.p12) once, in App settings › The app › Google Play › Add the key, or through your agent from a path on your computer. The password comes from you and is never written to a file. - A lost key, or one you'd rather replace: Ploa makes a new one and gives you its certificate for Play Console › Test and release › App integrity › Upload key › Request upload key reset.
Only you, or an agent you made autonomous, can add or replace the key, and the workspace's owners are told each time. A key Ploa already holds is replaced only when you ask, since Play rejects builds signed with a key it doesn't know.
Straight to Google Play or through Ploa. Straight is the default, in App settings › The app › Google Play › How Android builds are uploaded. If the direct upload doesn't go through, the bundle goes to Ploa instead, which signs it and uploads it to Google Play; the build carries on and the run notes it. A pull request's build always goes through Ploa, for its install link. On the Pro and Team plans, pick Through Ploa to send every build that way. On Free, builds go straight, and an app that had picked Through Ploa goes straight while the workspace is on Free, then through Ploa again once it's on Pro or Team.
Flutter, React Native, Capacitor and native apps
Ploa builds these with their own tools, wherever builds run (your Mac, GitHub or your Google Cloud), and signs them as it signs an Expo app. Nothing to set up beyond what the project already has.
- Flutter:
flutter build appbundle, and for iOSflutter build ios --config-only, pods and an unsigned archive. The version comes frompubspec.yaml. Where Flutter isn't installed (GitHub, your Google Cloud), Ploa fetches the version the project pins in.fvmrc, else stable. - React Native without Expo: Gradle in
android/, pods and Xcode inios/. - Capacitor and Ionic: the web part with its
buildscript,npx cap sync, then Gradle or Xcode. Add the native projects once withnpx cap add android/iosand commit them. - Android (Kotlin or Java):
./gradlew :app:bundleReleasein the Gradle project, with the version fromversionName. - Xcode (Swift or Objective-C): an unsigned archive of the app's scheme, with the version from
MARKETING_VERSION.
The build number goes up by itself, one past what the store has. Linking reads the app's IDs from its Gradle file and Xcode project, and the bundle IDs and entitlements for signing from the Xcode project, so push and App Groups still get turned on at Apple.
Apps built any way
Unity, .NET MAUI or anything else: build it as you do, and Ploa ships the file with the version it says.
ploa init --aab build/app.aab --ipa build/App.ipa # once, to link the app
ploa release android --aab build/app.aab
ploa release ios --ipa build/App.ipaAn .ipa, .app or .xcarchive not signed for the App Store yet (development, ad hoc, unsigned) is signed by Ploa the same way, on any machine. --resign re-signs even one that is. An app whose builds reach the stores from elsewhere altogether can be set to Where it's built: Elsewhere (see Stores and apps).
Files a build reads that git doesn't have
A build from a repository (GitHub, your Google Cloud) doesn't see files that aren't committed, like google-services.json, GoogleService-Info.plist or the .env files Expo loads. Ploa finds which are missing from the app's config on the release branch, and the GitHub status says so.
- Your agent sends them from your checkout with
ploa_build_files_push. Only names, sizes and a.env's variable names pass through it, never the values. - On the dashboard, the build files window asks you to pick the app's folder, and the browser looks in it for only the files the build reads. Nothing is sent until you add what it found.
- From the terminal,
ploa fileslists them andploa files pushsends them.
Either way they wait, sealed in the vault, until you approve them with one tap on ploa.dev. Files you don't approve within 24 hours are dropped. Store and signing keys are never build files: Ploa has them already. google-services.json and GoogleService-Info.plist aren't secret, so committing them is the other way.
Starting and stopping a build
On the dashboard, nothing starts or stops on one click. Make a build, in the dark bar at the bottom of the app's pages, opens the build window, where you pick the platforms and where it builds; a second step then lists the app, the platforms, where it builds, the commit or branch, what it costs and where it goes. Only Yes, start the build starts it, and Back keeps what you picked.
A build that's still building has Stop on its live tile, in its window and on its release page. A window says what stopping does where it runs, then asks once more:
| Where it runs | What Stop does |
|---|---|
| GitHub Actions | GitHub cancels the run. |
| Your Google Cloud | Cloud Build cancels it. |
| Xcode Cloud | Apple's API can't stop a run, so Ploa stops following it and says so: it keeps running until you stop it in App Store Connect or Xcode. |
| A computer with the CLI | The CLI stops when Ploa refuses its next progress report. |
- A build can be stopped only while it's building. Once the file is on its way to the store, it goes through.
- A stopped build reads Stopped by who and when, in grey rather than red, and its timeline says it was stopped from Ploa. It can't be picked up again: start a new one.
- A build cancelled where it ran, outside Ploa (GitHub's run page, Cloud Build's console, Xcode Cloud), shows as stopped outside Ploa, not as a failure.
- If GitHub or Cloud Build didn't stop it, Ploa says so and where to stop it.
- Your agent stops a build in two steps like the dashboard: it first tells you what stopping does, and stops it only after you say yes. The dashboard then says which agent stopped it.
While a build waits to start
A build that hasn't started yet says what it waits for: Waiting for a GitHub runner on GitHub, Preparing the machine in Google Cloud (the machine downloads the build image, usually 1 to 3 minutes), and Waiting for a Mac in Xcode Cloud.
Following a build
- Your agent follows it with
ploa_release_status(the stage, and the build log's last lines while it builds on your computer) andploa_runs(each step with its time, and why one failed).ploa_shipping_reportsays where everything stands. - On the dashboard, the app's Versions tab shows every build live, with each step and its time, and the strip above the pages shows the one running. A build's failure, or a store's refusal, shows in its own row and window.
- From the terminal,
ploa releaseprints each step as it goes, andploa statusshows what the stores have.
A build is done once it reaches testers. A request at its end (App Review, production) is a step of its own: if a store refuses it, the build still reads done, and the request shows as yours to ask again. You get a notification when a build reaches testers or fails.
Ploa doesn't keep your builds. A build is deleted from Ploa as soon as the store has it.
From the terminal
Without an agent, the same builds start from the CLI in the app's folder:
ploa preflight # what the stores would reject ploa release ios # build on this Mac, sign, TestFlight ploa release android # build, sign, internal testing ploa release ios --build-number 42 # a build number of your choice ploa release android --gcp # in your Google Cloud ploa release ios --xcode-cloud # in Xcode Cloud ploa files push # build files git doesn't have