diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 53af236ad..781136c86 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -497,6 +497,14 @@ Looked in: ); } + // Track which arch paths AGP didn't produce, so we can surface them at + // the end if zero archs uploaded. AGP omits an arch directory whenever + // the project filters it out via `ndk.abiFilters`, `splits.abi`, or + // `jniLibs.excludes`. Iterating only over present files lets a filtered + // release succeed instead of crashing on the first missing arch + // (https://github.com/shorebirdtech/shorebird/issues/3388). + final missingArchPaths = []; + var uploadedArchCount = 0; for (final arch in architectures) { final artifactPath = p.join( archsDir.path, @@ -504,6 +512,15 @@ Looked in: 'libapp.so', ); final artifact = File(artifactPath); + if (!artifact.existsSync()) { + logger.detail( + 'Skipping ${arch.arch}: no libapp.so at $artifactPath. ' + 'This is expected if the project filters this ABI via ' + 'ndk.abiFilters, splits.abi, or jniLibs.excludes.', + ); + missingArchPaths.add(artifactPath); + continue; + } final hash = sha256.convert(await artifact.readAsBytes()).toString(); logger.detail('Uploading artifact for $artifactPath'); @@ -518,7 +535,9 @@ Looked in: canSideload: false, podfileLockHash: null, ); + uploadedArchCount++; } on CodePushConflictException catch (_) { + uploadedArchCount++; // Newlines are due to how logger.info interacts with logger.progress. logger.info(''' @@ -532,6 +551,25 @@ ${arch.arch} artifact already exists, continuing...'''); } } + if (uploadedArchCount == 0) { + _handleErrorAndExit( + Exception('No architecture artifacts found to upload.'), + progress: createArtifactProgress, + message: + ''' +No architecture artifacts found to upload. + +Shorebird looked for libapp.so under ${archsDir.path} but every requested +architecture was missing: +${missingArchPaths.map((p) => ' - $p').join('\n')} + +This usually means your project's ndk.abiFilters / splits.abi / jniLibs.excludes +configuration excludes every architecture Shorebird was asked to build. Either +relax those filters or pass `--target-platform=` to restrict Shorebird +to the architectures your project actually builds.''', + ); + } + try { logger.detail('Uploading artifact for $aabPath'); await codePushClient.createReleaseArtifact( diff --git a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart index 669c53010..8b437e78f 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/android_patcher.dart @@ -192,6 +192,18 @@ Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebir ); logger.detail('Creating artifact for $patchArtifactPath'); final patchArtifact = File(patchArtifactPath); + // Skip archs the build didn't produce (ndk.abiFilters / splits.abi / + // jniLibs.excludes). Without this, a release built for a subset of + // archs can't be patched without hitting PathNotFoundException + // (https://github.com/shorebirdtech/shorebird/issues/3388). + if (!patchArtifact.existsSync()) { + logger.detail( + 'Skipping ${arch.arch}: no libapp.so at $patchArtifactPath. ' + 'This is expected if the project filters this ABI via ' + 'ndk.abiFilters, splits.abi, or jniLibs.excludes.', + ); + continue; + } final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); final hashSignature = await signHash(hash); @@ -212,6 +224,13 @@ Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebir throw ProcessExit(ExitCode.software.code); } } + if (patchArtifactBundles.isEmpty) { + createDiffProgress.fail( + 'No patch artifacts produced: no libapp.so found for any architecture ' + 'under ${patchArchsBuildDir.path}.', + ); + throw ProcessExit(ExitCode.software.code); + } createDiffProgress.complete(); return patchArtifactBundles; } diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index 1802cbba0..9174f4b04 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -1489,6 +1489,129 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' verify(() => progress.complete()).called(1); verifyNever(() => progress.fail(any())); }); + + test( + 'skips arches whose libapp.so is missing (abiFilters case)', + () async { + setUpProjectRoot(); + // Simulate AGP filtering: remove armeabi-v7a libapp.so to mirror + // a project with ndk.abiFilters that excludes arm32. + final missingArch = Arch.arm32; + File( + p.join( + projectRoot.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + missingArch.androidBuildPath, + 'libapp.so', + ), + ).deleteSync(); + + await runWithOverrides( + () async => codePushClientWrapper.createAndroidReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + platform: releasePlatform, + projectRoot: projectRoot.path, + aabPath: p.join(projectRoot.path, aabPath), + architectures: Arch.values, + ), + ); + + // One upload per surviving arch, plus one for the aab. + final expectedUploads = Arch.values.length - 1 + 1; + verify( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ).called(expectedUploads); + verifyNever( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: missingArch.arch, + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ); + verify(() => progress.complete()).called(1); + verifyNever(() => progress.fail(any())); + }, + ); + + test( + 'exits with code 70 when every requested arch is missing', + () async { + // Create the archs directory but no libapp.so files inside it. + // This mirrors AGP producing an empty strip output (all archs + // filtered out). + for (final archMetadata in Arch.values) { + Directory( + p.join( + projectRoot.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + archMetadata.androidBuildPath, + ), + ).createSync(recursive: true); + } + File(p.join(projectRoot.path, aabPath)).createSync(recursive: true); + + await expectLater( + () async => runWithOverrides( + () async => codePushClientWrapper.createAndroidReleaseArtifacts( + appId: app.appId, + releaseId: releaseId, + platform: releasePlatform, + projectRoot: projectRoot.path, + aabPath: p.join(projectRoot.path, aabPath), + architectures: Arch.values, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail( + any( + that: contains('No architecture artifacts found to upload'), + ), + ), + ).called(1); + verifyNever( + () => codePushClient.createReleaseArtifact( + appId: any(named: 'appId'), + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + canSideload: any(named: 'canSideload'), + podfileLockHash: any(named: 'podfileLockHash'), + ), + ); + }, + ); }); group('createWindowsReleaseArtifacts', () { diff --git a/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart index 73e045b6c..6981abad2 100644 --- a/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/android_patcher_test.dart @@ -561,6 +561,84 @@ Looked in: }); }); + group('when one arch is missing from the patch build', () { + setUp(() { + // Create every arch's libapp.so, then delete one to simulate AGP + // filtering a single ABI via ndk.abiFilters / splits.abi. + setUpProjectRootArtifacts(); + patchArtifactForArch(Arch.arm32).deleteSync(); + + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async { + final tempDir = Directory.systemTemp.createTempSync(); + final diffPath = p.join(tempDir.path, 'diff'); + File(diffPath) + ..createSync() + ..writeAsStringSync('test'); + return diffPath; + }); + }); + + test('skips missing arch and returns bundles for the rest', () async { + final result = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + releaseArtifact: File('release.aab'), + ), + ); + + expect(result.keys, isNot(contains(Arch.arm32))); + expect(result, hasLength(Arch.values.length - 1)); + verifyNever(() => progress.fail(any())); + }); + }); + + group('when every arch is missing from the patch build', () { + setUp(() { + // Create the archs directory layout so androidArchsDirectory + // resolves, but leave each arch directory empty. + for (final arch in Arch.values) { + Directory( + p.join( + projectRoot.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + arch.androidBuildPath, + ), + ).createSync(recursive: true); + } + }); + + test('fails progress and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: 'appId', + releaseId: 0, + releaseArtifact: File('release.aab'), + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail( + any(that: contains('No patch artifacts produced')), + ), + ).called(1); + }); + }); + group('when patch artifacts successfully created', () { setUp(() { setUpProjectRootArtifacts();