Section 11
When it breaks
The failures you will actually meet, in rough order of frequency.
Start with leas doctor
Most of the table below is diagnosable before a build ever starts. leas doctor
resolves every credential the way a build would, reads what is actually inside them, and calls
Apple and Google to confirm the keys work — in about two seconds.
leas doctor # everything, including live API checks
leas doctor --offline # skip the calls to Apple and Google
leas doctor --json # machine-readable, for CI
It exits non-zero when anything is broken, so it works as a gate in front of an expensive cloud build. The checks worth knowing about:
- Identifier drift. That
app.json,leas.json, and the provisioning profile all name the same app. This is the single most common cause of a failed build, and it is invisible untilxcodebuildrefuses. - Certificate and profile together. A certificate the profile does not list
cannot sign against it.
doctorcompares the fingerprint of the certificate inside your.p12against the ones the profile embeds. - Ad-hoc masquerading as App Store. A profile that provisions specific devices
is an ad-hoc profile. Exporting it with
app-storefails late and confusingly. - Expiry, early. Certificates last three years and profiles one. It warns thirty days out instead of on the morning you need to ship a hotfix.
- The store APIs, for real. It distinguishes a rejected key (401) from a key with no role granted (403), and opens and immediately abandons a Play edit — the only way to prove the service account was actually granted release permission on that app.
- The fleet. That the workflows exist and every secret they reference is present on the repository.
| What you see | What it means | Fix |
|---|---|---|
No signing certificate "iOS Distribution" found |
The certificate expired, was revoked, or belongs to a different team than the profile. | Section 8, "Replace an expired iOS distribution certificate." |
Provisioning profile doesn't include signing certificate |
The profile was generated against a certificate you have since replaced. | Regenerate the profile against the current certificate and re-upload the secret. |
| The build hangs at the codesign step and eventually times out | macOS is showing an invisible keychain permission prompt on a headless machine. | leas handles this with set-key-partition-list. If you see it, the build is
not going through leas — check for a stray custom signing script. |
altool: Unable to authenticate |
Wrong Issuer ID, or the .p8 is not where altool expects it. |
Confirm the Issuer ID matches the team that owns the key. leas installs the file into
~/.appstoreconnect/private_keys/ automatically. |
403 · The caller does not have permission |
The service account exists in Google Cloud but was never granted access in Play Console — or lacks access to this specific app. | Setup step 7, second half. |
APK signature is invalid / wrong signing key |
The artifact was re-signed with a keystore Play does not recognise as your upload key. | Confirm LEAS_ANDROID_KEYSTORE_BASE64 is the keystore actually registered with
this app. If it genuinely is lost, request an upload key reset. |
Version code N has already been used |
Play accepts each version code exactly once, forever. | Bump android.versionCode in app.json and rebuild. Never reuse. |
SDK location not found |
ANDROID_HOME is unset on the machine running the build. |
Set it locally; the workflow's setup-android step handles the cloud. |
| Published an update and nothing reaches the device | The update's runtime version does not match the installed binary's — the most common update problem by a wide margin. | leas update:list and compare against the build on the device. Section 6. |
| App crashes on launch right after an update | The JS calls native code the installed binary does not contain. | leas update:rollback --embedded immediately, then ship it as a build
instead and bump the runtime version. |
| Assets 404 after an update downloads its manifest | The update server was started with the wrong --url, so asset URLs point
somewhere devices cannot reach. |
Restart it with the public base URL and republish nothing — manifests are generated per request. |
| Cloud build fails but works locally | Almost always a secret that is missing, stale, or was pasted with a trailing newline. | gh secret list to check what exists, then re-upload the suspect one. |
Debugging order that saves the most time
Reproduce locally first. A local build prints the full toolchain error; a cloud build prints a log you have to go find and read. If it works locally and fails in the cloud, the problem is a secret — not your code.