Add per-ABI split APKs to the Android release pipeline - #6139
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the Android release build pipeline to additionally produce and archive per-ABI split APKs (arm64-v8a and armeabi-v7a) for sideload distribution, while keeping the default AAB/universal-APK flow unchanged unless explicitly opted in via a Gradle property.
Changes:
- Add a Gradle
splits.abiconfiguration gated behind-PabiSplits, while preservingndk.abiFiltersfor normal builds. - Extend the release deploy script to run
assembleRelease -PabiSplitsand archive the resulting per-ABI APKs alongside existing artifacts. - Add a CHANGELOG entry describing the new per-ABI APK artifacts and expected size reduction.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
scripts/deploy.ts |
Runs an additional Gradle assemble step with -PabiSplits and archives per-ABI APK outputs. |
android/app/build.gradle |
Adds an opt-in ABI split configuration and conditionally applies ndk.abiFilters to avoid AGP conflicts. |
CHANGELOG.md |
Documents the addition of per-ABI Android release APK artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (1)
scripts/deploy.ts:539
- The split-APK archiving hard-codes both the ABI list and the expected output filenames. This can drift from the Gradle
splits { abi { include ... } }configuration and will fail if the ABI list changes or if additional ABIs are added. Consider discovering the generated split APKs in the output directory and parsing the ABI from the filename instead of maintaining a second ABI list here.
const splitApkDir = join(guiPlatformDir, 'app/build/outputs/apk/release')
buildObj.abiApkFiles = {}
for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
const archivedApk = join(archiveDir, `${outfile}-${abi}.apk`)
fs.copyFileSync(join(splitApkDir, `app-${abi}-release.apk`), archivedApk)
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (1)
scripts/deploy.ts:634
- The per-ABI Zealot upload uses
call(curl ...), andcalllogs the full command line. That will printzealotApiTokenand the ABI channel key into build logs (credential leakage). Also,curlshould use--failso HTTP 4xx/5xx causes a non-zero exit code instead of silently succeeding.
mylog(`\n\nUploading ${abi} APK to Zealot: ${zealotUrl}`)
call(
`curl -X POST "${zealotUrl}/api/apps/upload?token=${zealotApiToken}&channel_key=${abiChannelKey}&branch=${branch}&git_commit=${gitCommit}" -F "file=@${abiApkFiles[abi]}"`
)
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (3)
scripts/deploy.ts:543
- The comment claims each split APK is “roughly half” the size of the universal APK, but the PR description measurements show closer to ~20–25% smaller (~30 MiB). Keeping this accurate helps avoid confusion when validating artifacts.
// per-ABI APKs for distribution outside Google Play (Play already
// serves per-ABI installs from the AAB). Each one is roughly half the
// size of the universal APK. The gradle daemon reuses the compile work
// from the bundle task above, so this only pays for packaging and
// signing. Maestro builds skip this entirely:
android/app/build.gradle:115
- This comment says each split APK is “roughly half” the size of the universal APK, but the PR description indicates ~20–25% smaller (~30 MiB). Consider updating the wording to match measured sizes so future readers don’t expect a 50% reduction.
// Edge addition: sideloadable per-ABI APKs for distribution outside
// Google Play (Play already serves per-ABI installs from the AAB).
// Each one is roughly half the size of the universal APK. Off by
// default so normal builds are unchanged; the deploy script opts in:
scripts/deploy.ts:557
abivalues come from deploy-config and are used to construct file paths. If an unexpected value is present (typo, or something like '../...'), this will currently fail with an unhelpful ENOENT (or potentially write outside the archive dir). Validate the ABI string and produce a clear error if the expected split APK isn’t found.
for (const { abi } of splitArchitectures) {
const archivedApk = join(archiveDir, `${outfile}-${abi}.apk`)
fs.copyFileSync(join(splitApkDir, `app-${abi}-release.apk`), archivedApk)
buildObj.abiApkFiles[abi] = archivedApk
}
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (4)
scripts/deploy.ts:649
- This upload uses
call(...), which logs the full command string. Because the Zealot API token is embedded in the URL, it will be printed to logs and can leak via build output retention. Prefer executing the command without logging secrets (or log a redacted form).
mylog(`\n\nUploading ${abi} APK to Zealot: ${zealotUrl}`)
call(
`curl -X POST "${zealotUrl}/api/apps/upload?token=${zealotApiToken}&channel_key=${abiChannelKey}&branch=${branch}&git_commit=${gitCommit}" -F "file=@${apkFile}"`
)
scripts/deploy.ts:541
- The comment says each split APK is "roughly half" the size of the universal APK, but the PR description measurements show ~20–25% smaller. Keeping this accurate helps avoid setting incorrect expectations for artifact sizes.
// serves per-ABI installs from the AAB). Each one is roughly half the
// size of the universal APK. The gradle daemon reuses the compile work
scripts/deploy.ts:557
abiis taken directly from deploy-config and interpolated into a filename. Without validation, an unexpected value can produce confusing ENOENT failures (or even path traversal via..). Validate supported ABIs and fail with a clear error if the expected APK file is missing before copying/archiving.
for (const { abi } of splitArchitectures) {
const archivedApk = join(archiveDir, `${outfile}-${abi}.apk`)
fs.copyFileSync(join(splitApkDir, `app-${abi}-release.apk`), archivedApk)
buildObj.abiApkFiles[abi] = archivedApk
android/app/build.gradle:110
- This comment says each split APK is "roughly half" the size of the universal APK, but the PR description measurements are ~20–25% smaller. Updating this keeps the build documentation accurate.
// Each one is roughly half the size of the universal APK. Off by
9ccc70d to
26abec4
Compare
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (1)
scripts/deploy.ts:557
- If deploy-config includes an unexpected ABI (typo / unsupported) or Gradle output naming changes,
fs.copyFileSyncwill throw anENOENTwithout a clear message about which ABI/config entry caused the failure. Adding an existence check with an explicit error makes misconfiguration and CI failures much easier to diagnose.
for (const { abi } of splitArchitectures) {
const archivedApk = join(archiveDir, `${outfile}-${abi}.apk`)
fs.copyFileSync(join(splitApkDir, `app-${abi}-release.apk`), archivedApk)
buildObj.abiApkFiles[abi] = archivedApk
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
deploy.ts has read zealotUrl, zealotApiToken, zealotChannelKey, and zealotMaestroChannelKey from deploy-config.json for a while, but the sample file never listed them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A build with -PabiSplits emits three APKs: the universal (same size and lib set as a default build, arm ABIs only) plus one sideloadable APK per ABI, each roughly 30 MiB smaller than the universal. The property is read as a boolean flag, so -PabiSplits=false disables the feature from the command line, gradle.properties, or CI. Builds without the property are completely unchanged. ndk.abiFilters stays active in both modes, which keeps Intel ABIs out of every artifact. AGP 8.8 allows abiFilters to coexist with splits.abi, so no filter juggling is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cda933a to
a6defb0
Compare
Branches opt in through an optional splitArchitectures list in their android deploy-config block, mapping each ABI to the Zealot channel that receives it. Configured branches run assembleRelease -PabiSplits after the existing bundle step (the gradle daemon reuses the compile work, so this only pays for packaging and signing) and archive the per-ABI APKs next to the AAB and universal APK. ABI names are checked against the ABIs the gradle splits block can actually emit, so a config typo fails with a clear error instead of a stray file path. One channel per ABI keeps each channel's latest build on the right architecture. The universal APK keeps serving the main channel, the rsync location, and direct downloads. Google Play is unaffected: it already serves per-ABI installs from the AAB. Maestro builds and unconfigured branches skip the feature entirely. Entries without a zealotChannelKey are archived but not uploaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Edge uploads self-signed APKs to Google Play instead of an AAB, since Play App Signing would require sharing the signing key with Google. Serving per-ABI APKs on Play therefore means a multi-APK release, which requires a unique versionCode per APK. The base Android versionCode becomes the build number times 10, and the split APKs add per-ABI offsets: armeabi-v7a +1, arm64-v8a +2. Play serves the highest compatible code, so 64-bit devices get the arm64 APK. The universal APK keeps the base code as the lowest-priority fallback, every artifact of a release outranks every artifact of the release before it, and codes never collide even for same-day builds, whose build numbers only advance by 1. Verified locally: a split build produces base, base+1, and base+2, a default build keeps the plain base, and updateVersion.ts writes versionCode 260804010 for build 26080401 while iOS keeps the raw build number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a6defb0 to
2903346
Compare
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Files excluded by content exclusion policy (1)
- deploy-config.sample.json
Suppressed comments (3)
scripts/deploy.ts:542
- The comment says each split APK is “roughly half the size” of the universal APK, but the PR description’s measured release artifacts are ~20–25% smaller. Keeping the comment aligned avoids misleading future maintainers.
// Branches configured with splitArchitectures also archive sideloadable
// per-ABI APKs for distribution outside Google Play (Play already
// serves per-ABI installs from the AAB). Each one is roughly half the
// size of the universal APK. The gradle daemon reuses the compile work
// from the bundle task above, so this only pays for packaging and
android/app/build.gradle:112
- This comment says each per-ABI APK is “roughly half the size” of the universal APK, but the PR’s measured sizes are closer to ~20–25% smaller. Consider updating the wording to match reality so it doesn’t overpromise.
// Edge addition: sideloadable per-ABI APKs for distribution outside
// Google Play (Play already serves per-ABI installs from the AAB).
// Each one is roughly half the size of the universal APK. Off by
// default so normal builds are unchanged; the deploy script opts in:
// ./gradlew assembleRelease -PabiSplits
scripts/deploy.ts:562
splitArchitecturesis validated against an allowlist, but duplicate ABI entries will silently overwrite the archived file and can trigger repeated uploads for the same ABI. Since the intent is one channel per ABI, it’s safer to fail fast on duplicates in the config.
const supportedAbis = ['arm64-v8a', 'armeabi-v7a']
for (const { abi } of splitArchitectures) {
if (!supportedAbis.includes(abi)) {
throw new Error(`Unsupported abi "${abi}" in splitArchitectures`)
}
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
none
Requirements
If you have made any visual changes to the GUI. Make sure you have:
No visual changes: build pipeline only.
Description
Adds per-ABI Android APKs to release builds, distributed through dedicated Zealot channels and driven entirely by deploy-config, with a versionCode scheme that supports uploading them to Google Play as a multi-APK release (Edge uploads self-signed APKs rather than an AAB, since Play App Signing would require sharing the signing key with Google). Direct downloads keep the universal APK.
Measured on release builds from the same tree:
How it works:
app/build.gradlegains asplits.abiblock gated behind-PabiSplits, withndk.abiFiltersuntouched and always active. A split build emits three APKs: the universal (same size and lib set as a default build, arm ABIs only, verified) plus one per ABI, so the universal stays available across the board. Default invocations (bundleRelease,npm run android:release*, dev builds) are completely unchanged.Branches opt in through an optional
splitArchitectureslist in their android deploy-config block, mapping each ABI to the Zealot channel that receives it:Branch scoping comes from the config file, so the deploy script itself stays branch-agnostic. Branches without the field, and all maestro builds, skip the feature entirely. Entries without a
zealotChannelKeyare archived but not uploaded.For configured branches,
deploy.tsrunsassembleRelease -PabiSplitsafter the existing bundle step (the gradle daemon reuses the compile work, so this only pays for packaging and signing) and archives<outfile>-arm64-v8a.apkand<outfile>-armeabi-v7a.apknext to the AAB and universal APK, with the same signing config, so cross-updates between any of these artifacts keep working.One Zealot channel per ABI means each channel's latest build is always the right architecture. The universal APK keeps uploading to the existing channel and the rsync location for direct downloads.
The Android versionCode becomes the build number times 10, and split APKs add per-ABI offsets (armeabi-v7a +1, arm64-v8a +2). Play multi-APK releases require a unique versionCode per APK, and Play serves the highest compatible code, so 64-bit devices get the arm64 APK while the universal keeps the base code as the lowest-priority fallback. The stride means codes never collide even for same-day builds, and every artifact of a release outranks every artifact of the release before it, so cross-channel updates keep working.
deploy-config.sample.jsondocuments the new field and the previously undocumented Zealot fields.Verified: both gradle configuration modes pass, each split APK contains exactly one
lib/<abi>/tree (24 .so files each), the split-mode universal matches the default build's size and contains only the two arm ABI trees, versionCodes come out as base/base+1/base+2 on a split build and plain base on a default build (aapt2-checked), updateVersion.ts writes 260804010 for build 26080401, and lint plus the test suite pass.Ops steps to activate: create one Zealot channel per ABI in the Zealot admin, then add the
splitArchitectureslist to the master block ofdeploy-config.jsonon the build machine.