Skip to content

Add per-ABI split APKs to the Android release pipeline - #6139

Draft
peachbits wants to merge 4 commits into
developfrom
matthew/abi-split-apks
Draft

Add per-ABI split APKs to the Android release pipeline#6139
peachbits wants to merge 4 commits into
developfrom
matthew/abi-split-apks

Conversation

@peachbits

@peachbits peachbits commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

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:

artifact size vs universal
universal APK 136.0 MiB
arm64-v8a APK 106.7 MiB -29.4 MiB (-21.6%)
armeabi-v7a APK 104.0 MiB -32.0 MiB (-23.5%)

How it works:

  • app/build.gradle gains a splits.abi block gated behind -PabiSplits, with ndk.abiFilters untouched 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 splitArchitectures list in their android deploy-config block, mapping each ABI to the Zealot channel that receives it:

    "android": {
      "master": {
        "splitArchitectures": [
          { "abi": "arm64-v8a", "zealotChannelKey": "<hex>" },
          { "abi": "armeabi-v7a", "zealotChannelKey": "<hex>" }
        ]
      }
    }

    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 zealotChannelKey are archived but not uploaded.

  • For configured branches, deploy.ts runs assembleRelease -PabiSplits after the existing bundle step (the gradle daemon reuses the compile work, so this only pays for packaging and signing) and archives <outfile>-arm64-v8a.apk and <outfile>-armeabi-v7a.apk next 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.json documents 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 splitArchitectures list to the master block of deploy-config.json on the build machine.

Copilot AI lite review requested due to automatic review settings August 4, 2026 19:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.abi configuration gated behind -PabiSplits, while preserving ndk.abiFilters for normal builds.
  • Extend the release deploy script to run assembleRelease -PabiSplits and 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.

Comment thread android/app/build.gradle Outdated
Copilot AI review requested due to automatic review settings August 4, 2026 20:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread scripts/deploy.ts
Copilot AI review requested due to automatic review settings August 4, 2026 20:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ...), and call logs the full command line. That will print zealotApiToken and the ABI channel key into build logs (credential leakage). Also, curl should use --fail so 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]}"`
      )

Copilot AI review requested due to automatic review settings August 4, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • abi values 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
    }

Copilot AI review requested due to automatic review settings August 4, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • abi is 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

@peachbits
peachbits force-pushed the matthew/abi-split-apks branch 2 times, most recently from 9ccc70d to 26abec4 Compare August 4, 2026 21:14
Copilot AI review requested due to automatic review settings August 4, 2026 22:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.copyFileSync will throw an ENOENT without 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

Copilot AI review requested due to automatic review settings August 4, 2026 22:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread scripts/deploy.ts
peachbits and others added 2 commits August 4, 2026 15:51
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>
@peachbits
peachbits force-pushed the matthew/abi-split-apks branch from cda933a to a6defb0 Compare August 4, 2026 22:52
peachbits and others added 2 commits August 4, 2026 16:03
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>
Copilot AI review requested due to automatic review settings August 4, 2026 23:03
@peachbits
peachbits force-pushed the matthew/abi-split-apks branch from a6defb0 to 2903346 Compare August 4, 2026 23:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • splitArchitectures is 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`)
      }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants