@@ -21,6 +21,15 @@ const now = new Date()
2121const cutoffDate = new Date ( )
2222cutoffDate . setMonth ( now . getMonth ( ) - BUILD_ARCHIVE_MONTHS )
2323
24+ /**
25+ * One per-ABI split APK build: the ABI to package, and the Zealot
26+ * channel that receives it.
27+ */
28+ interface SplitArchitecture {
29+ abi : string
30+ zealotChannelKey ?: string
31+ }
32+
2433/**
2534 * Things we expect to be set in the config file:
2635 */
@@ -46,6 +55,11 @@ interface BuildConfigFile {
4655 zealotApiToken ?: string
4756 zealotChannelKey ?: string
4857 zealotMaestroChannelKey ?: string
58+ // Per-ABI split APKs to build, archive, and upload. Android only.
59+ // Belongs in a branch block of the android config, so each branch
60+ // opts in on its own. Entries without a zealotChannelKey are archived
61+ // but not uploaded:
62+ splitArchitectures ?: SplitArchitecture [ ]
4963 hockeyAppId : string
5064 hockeyAppTags : string
5165 hockeyAppToken : string
@@ -81,6 +95,7 @@ interface BuildObj extends BuildConfigFile {
8195 dSymFile : string
8296 dSymZip : string
8397 ipaFile : string // Also APK
98+ abiApkFiles ?: Record < string , string > // Android split APKs, by ABI
8499}
85100
86101interface LatestTestFile {
@@ -146,8 +161,6 @@ function makeProject(buildObj: BuildObj): void {
146161 buildObj ,
147162 config [ project ] [ buildObj . platformType ] [ buildObj . repoBranch ]
148163 )
149-
150- console . log ( buildObj )
151164}
152165
153166function makeCommonPost ( buildObj : BuildObj ) : void {
@@ -519,11 +532,51 @@ function buildAndroid(buildObj: BuildObj): void {
519532 const universalApk = join ( apkPathDir , 'universal.apk' )
520533 buildObj . ipaFile = join ( apkPathDir , `${ outfile } .apk` )
521534 fs . renameSync ( universalApk , buildObj . ipaFile )
535+
536+ // Branches configured with splitArchitectures also archive sideloadable
537+ // per-ABI APKs for distribution outside Google Play (Play already
538+ // serves per-ABI installs from the AAB). Each one is roughly 31 MB
539+ // smaller than the universal APK (about 78-80 MB vs 110 MB as
540+ // measured). The gradle daemon reuses the compile work from the bundle
541+ // task above, so this only pays for packaging and signing. Maestro
542+ // builds skip this entirely:
543+ const { splitArchitectures } = buildObj
544+ if (
545+ splitArchitectures != null &&
546+ splitArchitectures . length > 0 &&
547+ ! maestroBuild
548+ ) {
549+ // The gradle splits block can only emit these ABIs, so anything else
550+ // in the config is a typo. This list must match the splits.abi
551+ // include list in android/app/build.gradle. Checking before the
552+ // build turns a typo into an immediate, clear error rather than a
553+ // low-signal ENOENT from the copy below after a multi-minute
554+ // assemble, and keeps unvalidated config values out of the path
555+ // construction:
556+ const supportedAbis = [ 'arm64-v8a' , 'armeabi-v7a' ]
557+ for ( const { abi } of splitArchitectures ) {
558+ if ( ! supportedAbis . includes ( abi ) ) {
559+ throw new Error ( `Unsupported abi "${ abi } " in splitArchitectures` )
560+ }
561+ }
562+
563+ call ( './gradlew assembleRelease -PabiSplits' )
564+ const splitApkDir = join ( guiPlatformDir , 'app/build/outputs/apk/release' )
565+
566+ buildObj . abiApkFiles = { }
567+ for ( const { abi } of splitArchitectures ) {
568+ const archivedApk = join ( archiveDir , `${ outfile } -${ abi } .apk` )
569+ fs . copyFileSync ( join ( splitApkDir , `app-${ abi } -release.apk` ) , archivedApk )
570+ buildObj . abiApkFiles [ abi ] = archivedApk
571+ }
572+ }
522573}
523574
524575function buildCommonPost ( buildObj : BuildObj ) : void {
525576 const {
577+ abiApkFiles,
526578 maestroBuild,
579+ splitArchitectures,
527580 zealotApiToken,
528581 zealotChannelKey,
529582 zealotMaestroChannelKey,
@@ -564,14 +617,16 @@ function buildCommonPost(buildObj: BuildObj): void {
564617 mylog ( '\nUploaded to HockeyApp' )
565618 }
566619
620+ // Shared by both Zealot uploads below:
621+ const branch = encodeURIComponent ( buildObj . repoBranch )
622+ const gitCommit = encodeURIComponent ( buildObj . guiHash )
623+
567624 // Maestro test builds upload to their own channel so they do not pollute the
568625 // production channel's release list. Production builds use zealotChannelKey.
569626 // A maestro build with no zealotMaestroChannelKey configured skips Zealot.
570627 const channelKey = maestroBuild ? zealotMaestroChannelKey : zealotChannelKey
571628
572629 if ( zealotApiToken != null && zealotUrl != null && channelKey != null ) {
573- const branch = encodeURIComponent ( buildObj . repoBranch )
574- const gitCommit = encodeURIComponent ( buildObj . guiHash )
575630 chdir ( buildObj . guiDir )
576631 const changes = cmd (
577632 `git diff HEAD^ HEAD CHANGELOG.md | { grep '^+[^+]' || true; }`
@@ -582,12 +637,40 @@ function buildCommonPost(buildObj: BuildObj): void {
582637 '***********************************************************************\n'
583638 )
584639
585- call (
586- `curl -X POST "${ zealotUrl } /api/apps/upload?token=${ zealotApiToken } &channel_key=${ channelKey } &branch=${ branch } &git_commit=${ gitCommit } &changelog=${ changelog } " -F "file=@${ buildObj . ipaFile } "`
640+ const token = encodeURIComponent ( zealotApiToken )
641+ const encodedChannelKey = encodeURIComponent ( channelKey )
642+ callRedacted (
643+ `curl -X POST "${ zealotUrl } /api/apps/upload?token=${ token } &channel_key=${ encodedChannelKey } &branch=${ branch } &git_commit=${ gitCommit } &changelog=${ changelog } " -F "file=@${ buildObj . ipaFile } "` ,
644+ [ token , encodedChannelKey ]
587645 )
588646 mylog ( '\n*** Upload to Zealot Complete ***' )
589647 }
590648
649+ // Architecture-specific APKs go to their own Zealot channels, one per
650+ // ABI, so each channel's latest build is always the right architecture.
651+ // The universal APK above keeps serving the main channel and direct
652+ // downloads. Only branches whose config block lists splitArchitectures
653+ // build these at all, and maestro builds never do:
654+ if (
655+ zealotApiToken != null &&
656+ zealotUrl != null &&
657+ abiApkFiles != null &&
658+ splitArchitectures != null
659+ ) {
660+ const token = encodeURIComponent ( zealotApiToken )
661+ for ( const { abi, zealotChannelKey : abiChannelKey } of splitArchitectures ) {
662+ const apkFile = abiApkFiles [ abi ]
663+ if ( abiChannelKey == null || apkFile == null ) continue
664+ const channelKey = encodeURIComponent ( abiChannelKey )
665+ mylog ( `\n\nUploading ${ abi } APK to Zealot: ${ zealotUrl } ` )
666+ callRedacted (
667+ `curl -X POST "${ zealotUrl } /api/apps/upload?token=${ token } &channel_key=${ channelKey } &branch=${ branch } &git_commit=${ gitCommit } " -F "file=@${ apkFile } "` ,
668+ [ token , channelKey ]
669+ )
670+ mylog ( `\n*** Upload of ${ abi } APK to Zealot Complete ***` )
671+ }
672+ }
673+
591674 if ( buildObj . rsyncLocation != null ) {
592675 const {
593676 buildNum,
@@ -713,8 +796,11 @@ function chdir(path: string): void {
713796 _currentPath = path
714797}
715798
716- function call ( cmdstring : string ) : void {
717- console . log ( 'call: ' + cmdstring )
799+ /**
800+ * Runs a command, inheriting our stdio. Shared by `call` and
801+ * `callRedacted` so both stay on the same execution options.
802+ */
803+ function execCommand ( cmdstring : string ) : void {
718804 childProcess . execSync ( cmdstring , {
719805 encoding : 'utf8' ,
720806 timeout : 3600000 ,
@@ -724,6 +810,35 @@ function call(cmdstring: string): void {
724810 } )
725811}
726812
813+ /**
814+ * Like `call`, but logs the command with the given secrets masked, so
815+ * tokens and keys do not land in the CI build log. A failed execSync
816+ * throws an error whose message embeds the raw command, so the failure
817+ * path gets the same masking as the log line.
818+ */
819+ function callRedacted ( cmdstring : string , secrets : string [ ] ) : void {
820+ const redact = ( text : string ) : string => {
821+ let out = text
822+ for ( const secret of secrets ) {
823+ if ( secret !== '' ) out = out . split ( secret ) . join ( '<redacted>' )
824+ }
825+ return out
826+ }
827+
828+ console . log ( 'call: ' + redact ( cmdstring ) )
829+ try {
830+ execCommand ( cmdstring )
831+ } catch ( error ) {
832+ const message = error instanceof Error ? error . message : String ( error )
833+ throw new Error ( redact ( message ) )
834+ }
835+ }
836+
837+ function call ( cmdstring : string ) : void {
838+ console . log ( 'call: ' + cmdstring )
839+ execCommand ( cmdstring )
840+ }
841+
727842function cmd ( cmdstring : string ) : string {
728843 console . log ( 'cmd: ' + cmdstring )
729844 const r = childProcess . execSync ( cmdstring , {
0 commit comments