Build xcarchive command (#67598)
diff --git a/dev/devicelab/bin/tasks/ios_content_validation_test.dart b/dev/devicelab/bin/tasks/ios_content_validation_test.dart
index e7f436e..a1964ae 100644
--- a/dev/devicelab/bin/tasks/ios_content_validation_test.dart
+++ b/dev/devicelab/bin/tasks/ios_content_validation_test.dart
@@ -166,6 +166,29 @@
if (!await localNetworkUsageFound(outputAppPath)) {
throw TaskResult.failure('Debug bundle is missing NSLocalNetworkUsageDescription');
}
+
+ section('Clean build');
+
+ await inDirectory(flutterProject.rootPath, () async {
+ await flutter('clean');
+ });
+
+ section('Archive');
+
+ await inDirectory(flutterProject.rootPath, () async {
+ await flutter('build', options: <String>[
+ 'xcarchive',
+ ]);
+ });
+
+ checkDirectoryExists(path.join(
+ flutterProject.rootPath,
+ 'build',
+ 'ios',
+ 'archive',
+ 'Runner.xcarchive',
+ 'Products',
+ ));
});
return TaskResult.success(null);
diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart
index 74a16a0..1410714 100644
--- a/packages/flutter_tools/lib/src/application_package.dart
+++ b/packages/flutter_tools/lib/src/application_package.dart
@@ -379,6 +379,12 @@
@override
String get deviceBundlePath => _buildAppPath('iphoneos');
+ // Xcode uses this path for the final archive bundle location,
+ // not a top-level output directory.
+ // Specifying `build/ios/archive/Runner` will result in `build/ios/archive/Runner.xcarchive`.
+ String get archiveBundlePath
+ => globals.fs.path.join(getIosBuildDirectory(), 'archive', globals.fs.path.withoutExtension(_hostAppBundleName));
+
String _buildAppPath(String type) {
return globals.fs.path.join(getIosBuildDirectory(), type, _hostAppBundleName);
}
diff --git a/packages/flutter_tools/lib/src/commands/build.dart b/packages/flutter_tools/lib/src/commands/build.dart
index 2446c9f..19cb541 100644
--- a/packages/flutter_tools/lib/src/commands/build.dart
+++ b/packages/flutter_tools/lib/src/commands/build.dart
@@ -28,6 +28,7 @@
buildSystem: globals.buildSystem,
verboseHelp: verboseHelp,
));
+ addSubcommand(BuildIOSArchiveCommand(verboseHelp: verboseHelp));
addSubcommand(BuildBundleCommand(verboseHelp: verboseHelp));
addSubcommand(BuildWebCommand(verboseHelp: verboseHelp));
addSubcommand(BuildMacosCommand(verboseHelp: verboseHelp));
diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart
index a8585db..a3d3438 100644
--- a/packages/flutter_tools/lib/src/commands/build_ios.dart
+++ b/packages/flutter_tools/lib/src/commands/build_ios.dart
@@ -19,24 +19,8 @@
/// Builds an .app for an iOS app to be used for local testing on an iOS device
/// or simulator. Can only be run on a macOS host. For producing deployment
/// .ipas, see https://flutter.dev/docs/deployment/ios.
-class BuildIOSCommand extends BuildSubCommand {
- BuildIOSCommand({ @required bool verboseHelp }) {
- addTreeShakeIconsFlag();
- addSplitDebugInfoOption();
- addBuildModeFlags(defaultToRelease: true);
- usesTargetOption();
- usesFlavorOption();
- usesPubOption();
- usesBuildNumberOption();
- usesBuildNameOption();
- addDartObfuscationOption();
- usesDartDefineOption();
- usesExtraFrontendOptions();
- addEnableExperimentation(hide: !verboseHelp);
- addBuildPerformanceFile(hide: !verboseHelp);
- addBundleSkSLPathOption(hide: !verboseHelp);
- addNullSafetyModeOptions(hide: !verboseHelp);
- usesAnalyzeSizeFlag();
+class BuildIOSCommand extends _BuildIOSSubCommand {
+ BuildIOSCommand({ @required bool verboseHelp }) : super(verboseHelp: verboseHelp) {
argParser
..addFlag('config-only',
help: 'Update the project configuration without performing a build. '
@@ -60,15 +44,75 @@
final String description = 'Build an iOS application bundle (Mac OS X host only).';
@override
+ final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.build;
+
+ @override
+ bool get forSimulator => boolArg('simulator');
+
+ @override
+ bool get configOnly => boolArg('config-only');
+
+ @override
+ bool get shouldCodesign => boolArg('codesign');
+}
+
+/// Builds an .xcarchive for an iOS app to be generated for App Store submission.
+/// Can only be run on a macOS host.
+/// For producing deployment .ipas, see https://flutter.dev/docs/deployment/ios.
+class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
+ BuildIOSArchiveCommand({ @required bool verboseHelp }) : super(verboseHelp: verboseHelp);
+
+ @override
+ final String name = 'xcarchive';
+
+ @override
+ final String description = 'Build an iOS archive bundle (Mac OS X host only).';
+
+ @override
+ final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.archive;
+
+ @override
+ final bool forSimulator = false;
+
+ @override
+ final bool configOnly = false;
+
+ @override
+ final bool shouldCodesign = true;
+}
+
+abstract class _BuildIOSSubCommand extends BuildSubCommand {
+ _BuildIOSSubCommand({ @required bool verboseHelp }) {
+ addTreeShakeIconsFlag();
+ addSplitDebugInfoOption();
+ addBuildModeFlags(defaultToRelease: true);
+ usesTargetOption();
+ usesFlavorOption();
+ usesPubOption();
+ usesBuildNumberOption();
+ usesBuildNameOption();
+ addDartObfuscationOption();
+ usesDartDefineOption();
+ usesExtraFrontendOptions();
+ addEnableExperimentation(hide: !verboseHelp);
+ addBuildPerformanceFile(hide: !verboseHelp);
+ addBundleSkSLPathOption(hide: !verboseHelp);
+ addNullSafetyModeOptions(hide: !verboseHelp);
+ usesAnalyzeSizeFlag();
+ }
+
+ @override
Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{
DevelopmentArtifact.iOS,
};
+ XcodeBuildAction get xcodeBuildAction;
+ bool get forSimulator;
+ bool get configOnly;
+ bool get shouldCodesign;
+
@override
Future<FlutterCommandResult> runCommand() async {
- final bool forSimulator = boolArg('simulator');
- final bool configOnly = boolArg('config-only');
- final bool shouldCodesign = boolArg('codesign');
defaultBuildMode = forSimulator ? BuildMode.debug : BuildMode.release;
final BuildInfo buildInfo = getBuildInfo();
@@ -99,7 +143,11 @@
final String logTarget = forSimulator ? 'simulator' : 'device';
final String typeName = globals.artifacts.getEngineType(TargetPlatform.ios, buildInfo.mode);
- globals.printStatus('Building $app for $logTarget ($typeName)...');
+ if (xcodeBuildAction == XcodeBuildAction.build) {
+ globals.printStatus('Building $app for $logTarget ($typeName)...');
+ } else {
+ globals.printStatus('Archiving $app...');
+ }
final XcodeBuildResult result = await buildXcodeProject(
app: app,
buildInfo: buildInfo,
@@ -107,11 +155,12 @@
buildForDevice: !forSimulator,
codesign: shouldCodesign,
configOnly: configOnly,
+ buildAction: xcodeBuildAction,
);
if (!result.success) {
await diagnoseXcodeBuildFailure(result, globals.flutterUsage, globals.logger);
- throwToolExit('Encountered error while building for $logTarget.');
+ throwToolExit('Encountered error while ${xcodeBuildAction.name}ing for $logTarget.');
}
if (buildInfo.codeSizeDirectory != null) {
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index f5ab4be..4f70b84 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -96,6 +96,7 @@
bool codesign = true,
String deviceID,
bool configOnly = false,
+ XcodeBuildAction buildAction = XcodeBuildAction.build,
}) async {
if (!upgradePbxProjWithFlutterAssets(app.project, globals.logger)) {
return XcodeBuildResult(success: false);
@@ -321,6 +322,14 @@
buildCommands.add('COMPILER_INDEX_STORE_ENABLE=NO');
buildCommands.addAll(environmentVariablesAsXcodeBuildSettings(globals.platform));
+ if (buildAction == XcodeBuildAction.archive) {
+ buildCommands.addAll(<String>[
+ '-archivePath',
+ globals.fs.path.absolute(app.archiveBundlePath),
+ 'archive',
+ ]);
+ }
+
final Stopwatch sw = Stopwatch()..start();
initialBuildStatus = globals.logger.startProgress('Running Xcode build...', timeout: timeoutConfiguration.slowOperation);
@@ -333,13 +342,13 @@
initialBuildStatus?.cancel();
initialBuildStatus = null;
globals.printStatus(
- 'Xcode build done.'.padRight(kDefaultStatusPadding + 1)
+ 'Xcode ${buildAction.name} done.'.padRight(kDefaultStatusPadding + 1)
+ getElapsedAsSeconds(sw.elapsed).padLeft(5),
);
- globals.flutterUsage.sendTiming('build', 'xcode-ios', Duration(milliseconds: sw.elapsedMilliseconds));
+ globals.flutterUsage.sendTiming(buildAction.name, 'xcode-ios', Duration(milliseconds: sw.elapsedMilliseconds));
// Run -showBuildSettings again but with the exact same parameters as the
- // build. showBuildSettings is reported to ocassionally timeout. Here, we give
+ // build. showBuildSettings is reported to occasionally timeout. Here, we give
// it a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
// When there is a timeout, we retry once. See issue #35988.
final List<String> showBuildSettingsCommand = (List<String>
@@ -398,36 +407,42 @@
),
);
} else {
- // If the app contains a watch companion target, the sdk argument of xcodebuild has to be omitted.
- // For some reason this leads to TARGET_BUILD_DIR always ending in 'iphoneos' even though the
- // actual directory will end with 'iphonesimulator' for simulator builds.
- // The value of TARGET_BUILD_DIR is adjusted to accommodate for this effect.
- String targetBuildDir = buildSettings['TARGET_BUILD_DIR'];
- if (hasWatchCompanion && !buildForDevice) {
- globals.printTrace('Replacing iphoneos with iphonesimulator in TARGET_BUILD_DIR.');
- targetBuildDir = targetBuildDir.replaceFirst('iphoneos', 'iphonesimulator');
- }
- final String expectedOutputDirectory = globals.fs.path.join(
- targetBuildDir,
- buildSettings['WRAPPER_NAME'],
- );
-
String outputDir;
- if (globals.fs.isDirectorySync(expectedOutputDirectory)) {
- // Copy app folder to a place where other tools can find it without knowing
- // the BuildInfo.
- outputDir = expectedOutputDirectory.replaceFirst('/$configuration-', '/');
- if (globals.fs.isDirectorySync(outputDir)) {
- // Previous output directory might have incompatible artifacts
- // (for example, kernel binary files produced from previous run).
- globals.fs.directory(outputDir).deleteSync(recursive: true);
+ if (buildAction == XcodeBuildAction.build) {
+ // If the app contains a watch companion target, the sdk argument of xcodebuild has to be omitted.
+ // For some reason this leads to TARGET_BUILD_DIR always ending in 'iphoneos' even though the
+ // actual directory will end with 'iphonesimulator' for simulator builds.
+ // The value of TARGET_BUILD_DIR is adjusted to accommodate for this effect.
+ String targetBuildDir = buildSettings['TARGET_BUILD_DIR'];
+ if (hasWatchCompanion && !buildForDevice) {
+ globals.printTrace('Replacing iphoneos with iphonesimulator in TARGET_BUILD_DIR.');
+ targetBuildDir = targetBuildDir.replaceFirst('iphoneos', 'iphonesimulator');
}
- globals.fsUtils.copyDirectorySync(
- globals.fs.directory(expectedOutputDirectory),
- globals.fs.directory(outputDir),
+ final String expectedOutputDirectory = globals.fs.path.join(
+ targetBuildDir,
+ buildSettings['WRAPPER_NAME'],
);
+ if (globals.fs.isDirectorySync(expectedOutputDirectory)) {
+ // Copy app folder to a place where other tools can find it without knowing
+ // the BuildInfo.
+ outputDir = expectedOutputDirectory.replaceFirst('/$configuration-', '/');
+ if (globals.fs.isDirectorySync(outputDir)) {
+ // Previous output directory might have incompatible artifacts
+ // (for example, kernel binary files produced from previous run).
+ globals.fs.directory(outputDir).deleteSync(recursive: true);
+ }
+ globals.fsUtils.copyDirectorySync(
+ globals.fs.directory(expectedOutputDirectory),
+ globals.fs.directory(outputDir),
+ );
+ } else {
+ globals.printError('Build succeeded but the expected app at $expectedOutputDirectory not found');
+ }
} else {
- globals.printError('Build succeeded but the expected app at $expectedOutputDirectory not found');
+ outputDir = '${globals.fs.path.absolute(app.archiveBundlePath)}.xcarchive';
+ if (!globals.fs.isDirectorySync(outputDir)) {
+ globals.printError('Archive succeeded but the expected xcarchive at $outputDir not found');
+ }
}
return XcodeBuildResult(
success: true,
@@ -568,6 +583,24 @@
}
}
+/// xcodebuild <buildaction> parameter (see man xcodebuild for details).
+///
+/// `clean`, `test`, `analyze`, and `install` are not supported.
+enum XcodeBuildAction { build, archive }
+
+extension XcodeBuildActionExtension on XcodeBuildAction {
+ String get name {
+ switch (this) {
+ case XcodeBuildAction.build:
+ return 'build';
+ case XcodeBuildAction.archive:
+ return 'archive';
+ default:
+ throw UnsupportedError('Unknown Xcode build action');
+ }
+ }
+}
+
class XcodeBuildResult {
XcodeBuildResult({
@required this.success,
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_xcarchive_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_xcarchive_test.dart
new file mode 100644
index 0000000..b7fa1da
--- /dev/null
+++ b/packages/flutter_tools/test/commands.shard/hermetic/build_xcarchive_test.dart
@@ -0,0 +1,216 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:file/memory.dart';
+import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/base/platform.dart';
+import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/commands/build.dart';
+import 'package:flutter_tools/src/ios/xcodeproj.dart';
+import 'package:flutter_tools/src/reporting/reporting.dart';
+import 'package:process/process.dart';
+
+import '../../src/common.dart';
+import '../../src/context.dart';
+import '../../src/testbed.dart';
+
+class FakeXcodeProjectInterpreterWithBuildSettings extends FakeXcodeProjectInterpreter {
+ @override
+ Future<Map<String, String>> getBuildSettings(
+ String projectPath, {
+ String scheme,
+ Duration timeout = const Duration(minutes: 1),
+ }) async {
+ return <String, String>{
+ 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject',
+ 'DEVELOPMENT_TEAM': 'abc',
+ };
+ }
+}
+
+final Platform macosPlatform = FakePlatform(
+ operatingSystem: 'macos',
+ environment: <String, String>{
+ 'FLUTTER_ROOT': '/',
+ }
+);
+final Platform notMacosPlatform = FakePlatform(
+ operatingSystem: 'linux',
+ environment: <String, String>{
+ 'FLUTTER_ROOT': '/',
+ }
+);
+
+void main() {
+ FileSystem fileSystem;
+ Usage usage;
+
+ setUpAll(() {
+ Cache.disableLocking();
+ });
+
+ setUp(() {
+ fileSystem = MemoryFileSystem.test();
+ usage = Usage.test();
+ });
+
+ // Sets up the minimal mock project files necessary to look like a Flutter project.
+ void createCoreMockProjectFiles() {
+ fileSystem.file('pubspec.yaml').createSync();
+ fileSystem.file('.packages').createSync();
+ fileSystem.file(fileSystem.path.join('lib', 'main.dart')).createSync(recursive: true);
+ }
+
+ // Sets up the minimal mock project files necessary for iOS builds to succeed.
+ void createMinimalMockProjectFiles() {
+ fileSystem.directory(fileSystem.path.join('ios', 'Runner.xcodeproj')).createSync(recursive: true);
+ fileSystem.directory(fileSystem.path.join('ios', 'Runner.xcworkspace')).createSync(recursive: true);
+ fileSystem.file(fileSystem.path.join('ios', 'Runner.xcodeproj', 'project.pbxproj')).createSync();
+ createCoreMockProjectFiles();
+ }
+
+ const FakeCommand xattrCommand = FakeCommand(command: <String>[
+ 'xattr', '-r', '-d', 'com.apple.FinderInfo', '/ios'
+ ]);
+
+ // Creates a FakeCommand for the xcodebuild call to build the app
+ // in the given configuration.
+ FakeCommand setUpMockXcodeBuildHandler({ bool verbose = false, bool showBuildSettings = false, void Function() onRun }) {
+ return FakeCommand(
+ command: <String>[
+ '/usr/bin/env',
+ 'xcrun',
+ 'xcodebuild',
+ '-configuration', 'Release',
+ if (verbose)
+ 'VERBOSE_SCRIPT_LOGGING=YES'
+ else
+ '-quiet',
+ '-workspace', 'Runner.xcworkspace',
+ '-scheme', 'Runner',
+ 'BUILD_DIR=/build/ios',
+ '-sdk', 'iphoneos',
+ 'FLUTTER_SUPPRESS_ANALYTICS=true',
+ 'COMPILER_INDEX_STORE_ENABLE=NO',
+ '-archivePath', '/build/ios/archive/Runner',
+ 'archive',
+ if (showBuildSettings)
+ '-showBuildSettings',
+ ],
+ stdout: 'STDOUT STUFF',
+ onRun: onRun,
+ );
+ }
+
+ testUsingContext('xcarchive build fails when there is no ios project', () async {
+ final BuildCommand command = BuildCommand();
+ createCoreMockProjectFiles();
+
+ expect(createTestCommandRunner(command).run(
+ const <String>['build', 'xcarchive', '--no-pub']
+ ), throwsToolExit(message: 'Application not configured for iOS'));
+ }, overrides: <Type, Generator>{
+ Platform: () => macosPlatform,
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.any(),
+ XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(),
+ });
+
+ testUsingContext('xcarchive build fails on non-macOS platform', () async {
+ final BuildCommand command = BuildCommand();
+ fileSystem.file('pubspec.yaml').createSync();
+ fileSystem.file('.packages').createSync();
+ fileSystem.file(fileSystem.path.join('lib', 'main.dart'))
+ .createSync(recursive: true);
+
+ expect(createTestCommandRunner(command).run(
+ const <String>['build', 'xcarchive', '--no-pub']
+ ), throwsToolExit());
+ }, overrides: <Type, Generator>{
+ Platform: () => notMacosPlatform,
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.any(),
+ XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(),
+ });
+
+ testUsingContext('xcarchive build invokes xcode build', () async {
+ final BuildCommand command = BuildCommand();
+ createMinimalMockProjectFiles();
+
+ await createTestCommandRunner(command).run(
+ const <String>['build', 'xcarchive', '--no-pub']
+ );
+ }, overrides: <Type, Generator>{
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
+ xattrCommand,
+ setUpMockXcodeBuildHandler(),
+ setUpMockXcodeBuildHandler(showBuildSettings: true),
+ ]),
+ Platform: () => macosPlatform,
+ XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(),
+ });
+
+ testUsingContext('xcarchive build invokes xcode build with verbosity', () async {
+ final BuildCommand command = BuildCommand();
+ createMinimalMockProjectFiles();
+
+ await createTestCommandRunner(command).run(
+ const <String>['build', 'xcarchive', '--no-pub', '-v']
+ );
+ }, overrides: <Type, Generator>{
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
+ xattrCommand,
+ setUpMockXcodeBuildHandler(verbose: true),
+ setUpMockXcodeBuildHandler(verbose: true, showBuildSettings: true),
+ ]),
+ Platform: () => macosPlatform,
+ XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(),
+ });
+
+ testUsingContext('Performs code size analysis and sends analytics', () async {
+ final BuildCommand command = BuildCommand();
+ createMinimalMockProjectFiles();
+
+ fileSystem.file('build/ios/Release-iphoneos/Runner.app/Frameworks/App.framework/App')
+ ..createSync(recursive: true)
+ ..writeAsBytesSync(List<int>.generate(10000, (int index) => 0));
+
+ // Capture Usage.test() events.
+ final StringBuffer buffer = await capturedConsolePrint(() =>
+ createTestCommandRunner(command).run(
+ const <String>['build', 'xcarchive', '--no-pub', '--analyze-size']
+ )
+ );
+
+ expect(testLogger.statusText, contains('A summary of your iOS bundle analysis can be found at'));
+ expect(buffer.toString(), contains('event {category: code-size-analysis, action: ios, label: null, value: null, cd33: '));
+ }, overrides: <Type, Generator>{
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
+ xattrCommand,
+ setUpMockXcodeBuildHandler(onRun: () {
+ fileSystem.file('build/flutter_size_01/snapshot.arm64.json')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('''[
+{
+ "l": "dart:_internal",
+ "c": "SubListIterable",
+ "n": "[Optimized] skip",
+ "s": 2400
+}
+ ]''');
+ fileSystem.file('build/flutter_size_01/trace.arm64.json')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('{}');
+ }),
+ setUpMockXcodeBuildHandler(showBuildSettings: true),
+ ]),
+ Platform: () => macosPlatform,
+ FileSystemUtils: () => FileSystemUtils(fileSystem: fileSystem, platform: macosPlatform),
+ Usage: () => usage,
+ XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(),
+ });
+}
diff --git a/packages/flutter_tools/test/general.shard/commands/build_test.dart b/packages/flutter_tools/test/general.shard/commands/build_test.dart
index 0c0acfd..a7c0e8e 100644
--- a/packages/flutter_tools/test/general.shard/commands/build_test.dart
+++ b/packages/flutter_tools/test/general.shard/commands/build_test.dart
@@ -29,6 +29,7 @@
BuildWebCommand(verboseHelp: false),
BuildApkCommand(verboseHelp: false),
BuildIOSCommand(verboseHelp: false),
+ BuildIOSArchiveCommand(verboseHelp: false),
BuildAppBundleCommand(verboseHelp: false),
BuildFuchsiaCommand(verboseHelp: false),
BuildAarCommand(verboseHelp: false),