fix(shorebird_cli): skip missing-arch libapp.so on Android upload and patch (#3793)

This commit is contained in:
Brandon DeRosier
2026-05-20 12:52:08 -07:00
committed by GitHub
parent aaee05a5b4
commit 6ddb854da2
4 changed files with 258 additions and 0 deletions
@@ -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 = <String>[];
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=<archs>` to restrict Shorebird
to the architectures your project actually builds.''',
);
}
try {
logger.detail('Uploading artifact for $aabPath');
await codePushClient.createReleaseArtifact(
@@ -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;
}
@@ -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', () {
@@ -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();