feat(shorebird_cli): reject ExportOptions.plist with manageAppVersionAndBuildNumber (#3678)

This commit is contained in:
Eric Seidel
2026-04-21 13:22:43 -07:00
committed by GitHub
parent 6b9fb7fedf
commit ab9afcea14
7 changed files with 305 additions and 2 deletions
@@ -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<void> 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<DiffStatus> assertUnpatchableDiffs({
required ReleaseArtifact releaseArtifact,
@@ -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.
''';
}
}
@@ -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';
@@ -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<String, Object> 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.''',
);
}
}
@@ -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('''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
$body
</dict>
</plist>
''');
}
test(
'returns normally when manageAppVersionAndBuildNumber is absent',
() async {
final file = writePlist(
'<key>method</key><string>app-store</string>',
);
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(
'<key>manageAppVersionAndBuildNumber</key><true/>',
);
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(
@@ -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('''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
$body
</dict>
</plist>
''');
}
test(
'returns normally when manageAppVersionAndBuildNumber is absent',
() async {
final file = writePlist(
'<key>method</key><string>app-store</string>',
);
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(
'<key>manageAppVersionAndBuildNumber</key><true/>',
);
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.
'''),
);
});
@@ -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('''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
$body
</dict>
</plist>
''');
}
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('<key>method</key><string>app-store</string>');
expect(() => assertValidExportOptionsPlist(file), returnsNormally);
});
test('does not throw when key is false', () {
final file = writePlist(
'<key>manageAppVersionAndBuildNumber</key><false/>',
);
expect(() => assertValidExportOptionsPlist(file), returnsNormally);
});
test('throws InvalidExportOptionsPlistException when key is true', () {
final file = writePlist(
'<key>manageAppVersionAndBuildNumber</key><true/>',
);
expect(
() => assertValidExportOptionsPlist(file),
throwsA(
isA<InvalidExportOptionsPlistException>().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<PlistParseException>()),
);
});
});
}