From ab9afcea14112d4b563508522a756993f44a16e9 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 21 Apr 2026 13:22:43 -0700 Subject: [PATCH] feat(shorebird_cli): reject ExportOptions.plist with manageAppVersionAndBuildNumber (#3678) --- .../lib/src/commands/patch/ios_patcher.dart | 16 ++++ .../src/commands/release/ios_releaser.dart | 22 +++++- .../lib/src/platform/apple/apple.dart | 1 + .../lib/src/platform/apple/plist.dart | 42 ++++++++++ .../src/commands/patch/ios_patcher_test.dart | 75 ++++++++++++++++++ .../commands/release/ios_releaser_test.dart | 79 ++++++++++++++++++- .../test/src/platform/apple/plist_test.dart | 72 +++++++++++++++++ 7 files changed, 305 insertions(+), 2 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart index 3659e9e6..86758356 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/ios_patcher.dart @@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/patch/patcher.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; @@ -104,6 +105,21 @@ class IosPatcher extends Patcher { } } + @override + Future assertArgsAreValid() async { + final exportOptionsPlistFile = argResults.file( + CommonArguments.exportOptionsPlistArg.name, + ); + if (exportOptionsPlistFile != null) { + try { + assertValidExportOptionsPlist(exportOptionsPlistFile); + } on InvalidExportOptionsPlistException catch (error) { + logger.err(error.message); + throw ProcessExit(ExitCode.usage.code); + } + } + } + @override Future assertUnpatchableDiffs({ required ReleaseArtifact releaseArtifact, 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 948e6271..db8ddf9f 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/release/releaser.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; @@ -60,6 +61,18 @@ To change the version of this release, change your app's version in your pubspec } await assertObfuscationIsSupported(); + + final exportOptionsPlistFile = argResults.file( + CommonArguments.exportOptionsPlistArg.name, + ); + if (exportOptionsPlistFile != null) { + try { + assertValidExportOptionsPlist(exportOptionsPlistFile); + } on InvalidExportOptionsPlistException catch (error) { + logger.err(error.message); + throw ProcessExit(ExitCode.usage.code); + } + } } @override @@ -88,6 +101,12 @@ To change the version of this release, change your app's version in your pubspec ) ..warn( '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', + ) + ..warn( + ''' +When you distribute the .xcarchive in Xcode, you MUST uncheck "Manage Version and Build Number" in the Distribute App dialog. + +If left checked, Xcode will rewrite the build number in the uploaded IPA, so the version that ships to App Store Connect will not match the version Shorebird recorded for this release. Patches will then fail to apply.''', ); } @@ -216,7 +235,8 @@ Your next step is to submit the archive at ${lightCyan.wrap(relativeArchivePath) You can open the archive in Xcode by running: ${lightCyan.wrap('open $relativeArchivePath')} -${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')} +${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number" in the Distribute App dialog.')} +If left checked, Xcode will rewrite the build number in the uploaded IPA, so the version that ships will not match the one Shorebird recorded for this release, and patches will fail to apply. '''; } } diff --git a/packages/shorebird_cli/lib/src/platform/apple/apple.dart b/packages/shorebird_cli/lib/src/platform/apple/apple.dart index 48de6c98..649e69bf 100644 --- a/packages/shorebird_cli/lib/src/platform/apple/apple.dart +++ b/packages/shorebird_cli/lib/src/platform/apple/apple.dart @@ -17,6 +17,7 @@ import 'package:xml/xml.dart'; export 'apple_platform.dart'; export 'export_method.dart'; +export 'invalid_export_options_plist_exception.dart'; export 'link_result.dart'; export 'macho.dart'; export 'missing_xcode_project_exception.dart'; diff --git a/packages/shorebird_cli/lib/src/platform/apple/plist.dart b/packages/shorebird_cli/lib/src/platform/apple/plist.dart index a35147cf..91960d04 100644 --- a/packages/shorebird_cli/lib/src/platform/apple/plist.dart +++ b/packages/shorebird_cli/lib/src/platform/apple/plist.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:propertylistserialization/propertylistserialization.dart'; +import 'package:shorebird_cli/src/platform/apple/invalid_export_options_plist_exception.dart'; +import 'package:shorebird_cli/src/shorebird_documentation.dart'; /// Exception thrown when a plist file cannot be parsed. class PlistParseException implements Exception { @@ -57,6 +59,16 @@ class Plist { /// This nesting is not present in Info.plist files in app bundles. static const applicationPropertiesKey = 'ApplicationProperties'; + /// The key in an ExportOptions.plist that, when true, instructs Xcode to + /// rewrite CFBundleVersion in the exported IPA based on the latest build + /// number on App Store Connect. This breaks Shorebird, because the build + /// number that ships will not match the one Shorebird recorded for the + /// release. + /// + /// See https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases + static const manageAppVersionAndBuildNumberKey = + 'manageAppVersionAndBuildNumber'; + /// The properties contained in the Info.plist file. late final Map properties; @@ -81,3 +93,33 @@ class Plist { String toString() => PropertyListSerialization.stringWithPropertyList(properties); } + +/// Asserts that the user-supplied `--export-options-plist` at [file] is +/// compatible with Shorebird. +/// +/// Throws [InvalidExportOptionsPlistException] if the plist sets +/// `manageAppVersionAndBuildNumber` to `true`. When that key is true, Xcode +/// rewrites `CFBundleVersion` in the exported IPA, so the build number that +/// ships to App Store Connect will not match the build number Shorebird +/// recorded for the release. Patches will then fail to match the release. +/// +/// Throws [PlistParseException] if the file cannot be parsed. Returns +/// without doing anything if the file does not exist; flutter will surface +/// a clearer error when it fails to read it. +void assertValidExportOptionsPlist(File file) { + if (!file.existsSync()) return; + final plist = Plist(file: file); + final value = plist.properties[Plist.manageAppVersionAndBuildNumberKey]; + if (value == true) { + throw InvalidExportOptionsPlistException( + ''' +Exported options plist ${file.path} sets "${Plist.manageAppVersionAndBuildNumberKey}" to true. + +Xcode will rewrite the build number in the exported IPA, so the version that ships to App Store Connect will not match the version Shorebird recorded for this release. Patches will fail to apply. + +Set "${Plist.manageAppVersionAndBuildNumberKey}" to false (or remove the key) and try again. + +See $troubleshootingUrl#patch-not-showing-up for details.''', + ); + } +} diff --git a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart index ac6e64a1..511cd355 100644 --- a/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/ios_patcher_test.dart @@ -267,6 +267,81 @@ void main() { }); }); + group('assertArgsAreValid', () { + test('returns normally when --export-options-plist is absent', () async { + await expectLater( + runWithOverrides(patcher.assertArgsAreValid), + completes, + ); + }); + + group('when --export-options-plist is provided', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync( + 'export_options_patcher_', + ); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + File writePlist(String body) { + return File(p.join(tempDir.path, 'ExportOptions.plist')) + ..writeAsStringSync(''' + + + + +$body + + +'''); + } + + test( + 'returns normally when manageAppVersionAndBuildNumber is absent', + () async { + final file = writePlist( + 'methodapp-store', + ); + when( + () => argResults[CommonArguments.exportOptionsPlistArg.name], + ).thenReturn(file.path); + + await expectLater( + runWithOverrides(patcher.assertArgsAreValid), + completes, + ); + }, + ); + + test( + '''logs error and exits with usage when manageAppVersionAndBuildNumber is true''', + () async { + final file = writePlist( + 'manageAppVersionAndBuildNumber', + ); + when( + () => argResults[CommonArguments.exportOptionsPlistArg.name], + ).thenReturn(file.path); + + await expectLater( + () => runWithOverrides(patcher.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + verify( + () => logger.err( + any(that: contains('manageAppVersionAndBuildNumber')), + ), + ).called(1); + }, + ); + }); + }); + group('assertUnpatchableDiffs', () { group('when no native changes are detected', () { const noChangeDiffStatus = DiffStatus( 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 a8d03d23..52a4a650 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 @@ -290,6 +290,72 @@ To change the version of this release, change your app's version in your pubspec ); }); }); + + group('when --export-options-plist is provided', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync( + 'export_options_releaser_', + ); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + File writePlist(String body) { + return File(p.join(tempDir.path, 'ExportOptions.plist')) + ..writeAsStringSync(''' + + + + +$body + + +'''); + } + + test( + 'returns normally when manageAppVersionAndBuildNumber is absent', + () async { + final file = writePlist( + 'methodapp-store', + ); + when( + () => argResults[CommonArguments.exportOptionsPlistArg.name], + ).thenReturn(file.path); + + await expectLater( + runWithOverrides(iosReleaser.assertArgsAreValid), + completes, + ); + }, + ); + + test( + '''logs error and exits with usage when manageAppVersionAndBuildNumber is true''', + () async { + final file = writePlist( + 'manageAppVersionAndBuildNumber', + ); + when( + () => argResults[CommonArguments.exportOptionsPlistArg.name], + ).thenReturn(file.path); + + await expectLater( + () => runWithOverrides(iosReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + verify( + () => logger.err( + any(that: contains('manageAppVersionAndBuildNumber')), + ), + ).called(1); + }, + ); + }); }); group('buildReleaseArtifacts', () { @@ -454,6 +520,16 @@ To change the version of this release, change your app's version in your pubspec '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', ), ).called(1); + verify( + () => logger.warn( + any( + that: allOf( + contains('Manage Version and Build Number'), + contains('Patches will then fail to apply'), + ), + ), + ), + ).called(1); }); }); @@ -1033,7 +1109,8 @@ Your next step is to submit the archive at ${lightCyan.wrap(p.relative(xcarchive You can open the archive in Xcode by running: ${lightCyan.wrap('open ${p.relative(xcarchiveDirectory.path)}')} -${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')} +${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number" in the Distribute App dialog.')} +If left checked, Xcode will rewrite the build number in the uploaded IPA, so the version that ships will not match the one Shorebird recorded for this release, and patches will fail to apply. '''), ); }); diff --git a/packages/shorebird_cli/test/src/platform/apple/plist_test.dart b/packages/shorebird_cli/test/src/platform/apple/plist_test.dart index da5b78e3..9307b8e6 100644 --- a/packages/shorebird_cli/test/src/platform/apple/plist_test.dart +++ b/packages/shorebird_cli/test/src/platform/apple/plist_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/platform/apple/invalid_export_options_plist_exception.dart'; import 'package:shorebird_cli/src/platform/apple/plist.dart'; import 'package:test/test.dart'; @@ -105,4 +106,75 @@ void main() { }); }); }); + + group('assertValidExportOptionsPlist', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('export_options_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + File writePlist(String body) { + return File(p.join(tempDir.path, 'ExportOptions.plist')) + ..writeAsStringSync(''' + + + + +$body + + +'''); + } + + test('does not throw when file does not exist', () { + final file = File(p.join(tempDir.path, 'missing.plist')); + expect(() => assertValidExportOptionsPlist(file), returnsNormally); + }); + + test('does not throw when key is absent', () { + final file = writePlist('methodapp-store'); + expect(() => assertValidExportOptionsPlist(file), returnsNormally); + }); + + test('does not throw when key is false', () { + final file = writePlist( + 'manageAppVersionAndBuildNumber', + ); + expect(() => assertValidExportOptionsPlist(file), returnsNormally); + }); + + test('throws InvalidExportOptionsPlistException when key is true', () { + final file = writePlist( + 'manageAppVersionAndBuildNumber', + ); + expect( + () => assertValidExportOptionsPlist(file), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('manageAppVersionAndBuildNumber'), + contains('Patches will fail to apply'), + contains('patch-not-showing-up'), + ), + ), + ), + ); + }); + + test('propagates PlistParseException for malformed plist', () { + final file = File(p.join(tempDir.path, 'ExportOptions.plist')) + ..writeAsStringSync('not a plist'); + expect( + () => assertValidExportOptionsPlist(file), + throwsA(isA()), + ); + }); + }); }