diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index a478b83f..183f5f5b 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -129,6 +129,22 @@ If left checked, Xcode will rewrite the build number in the uploaded IPA, so the throw ProcessExit(ExitCode.software.code); } + // When code signing is requested (the default), `flutter build ipa` is + // expected to export a signed .ipa. Flutter treats the export step as + // optional and exits 0 even when it fails (e.g. no signing certificate), + // so we must verify the .ipa was actually produced. Otherwise we would + // report a successful release and point the user at an .ipa that does not + // exist. See https://github.com/shorebirdtech/shorebird/issues/3807. + if (codesign && artifactManager.getIpa() == null) { + logger.err( + ''' +Unable to find generated IPA. This usually means that the IPA export step of "flutter build ipa" failed (for example, due to a missing or invalid code signing certificate). Review the build output above for the underlying error. + +If you do not need a signed IPA (for example, you will sign the .xcarchive in Xcode), re-run this command with --no-codesign.''', + ); + throw ProcessExit(ExitCode.software.code); + } + return xcarchiveDirectory; } diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index 38fd9f0e..07daf1de 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -423,6 +423,9 @@ $body when( () => artifactManager.getXcarchiveDirectory(), ).thenReturn(xcarchiveDirectory); + when( + () => artifactManager.getIpa(), + ).thenReturn(File(p.join(Directory.systemTemp.path, 'app.ipa'))); when( () => codeSigner.base64PublicKeyFromPem(any()), @@ -660,6 +663,42 @@ $body }); }); + group('when codesigning and ipa not found after build', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(true); + when(() => artifactManager.getIpa()).thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any(that: contains('Unable to find generated IPA')), + ), + ).called(1); + }); + }); + + group('when not codesigning and ipa not found after build', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + when(() => artifactManager.getIpa()).thenReturn(null); + }); + + test('does not check for the ipa and returns xcarchive path', () async { + expect( + await runWithOverrides(iosReleaser.buildReleaseArtifacts), + equals(xcarchiveDirectory), + ); + + verifyNever(() => artifactManager.getIpa()); + }); + }); + group('when --obfuscate is passed', () { setUp(() { when(() => argResults['obfuscate']).thenReturn(true);