Add validation for required fields during xcodebuild (#187772)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

- If the xcconfig is broken, flutter builds either fail or misbuild with
unexpected values such as missing version numbers

Fixes https://github.com/flutter/flutter/issues/140845
Fixes https://github.com/flutter/flutter/issues/12748

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

If this change needs to override an active code freeze, provide a
comment explaining why. The code freeze workflow can be overridden by
code reviewers. See pinned issues for any active code freezes with
guidance.

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/packages/flutter_tools/bin/xcode_backend.dart b/packages/flutter_tools/bin/xcode_backend.dart
index 2142ba2..9df4263 100644
--- a/packages/flutter_tools/bin/xcode_backend.dart
+++ b/packages/flutter_tools/bin/xcode_backend.dart
@@ -17,6 +17,29 @@
   ).run();
 }
 
+enum XcodeBuildCommand {
+  build('build', true),
+  prepare('prepare', true),
+  thin('thin'),
+  embed('embed', true),
+  embedAndThin('embed_and_thin', true),
+  buildAddToApp('build-add-to-app'),
+  testVmServiceBonjourService('test_vm_service_bonjour_service');
+
+  const XcodeBuildCommand(this.command, [this.requiresGeneratedXcconfigImport = false]);
+  final String command;
+  final bool requiresGeneratedXcconfigImport;
+
+  static XcodeBuildCommand? tryParse(String command) {
+    for (final XcodeBuildCommand value in values) {
+      if (value.command == command) {
+        return value;
+      }
+    }
+    return null;
+  }
+}
+
 /// Container for script arguments and environment variables.
 ///
 /// All interactions with the platform are broken into individual methods that
@@ -43,45 +66,71 @@
       exit(-1);
     }
 
-    final String subCommand = validateCommand(arguments[0]);
+    final XcodeBuildCommand subCommand = validateCommand(arguments[0]);
     final String? platformName = arguments.length < 2 ? null : arguments[1];
     final TargetPlatform platform = parsePlatform(platformName);
+    if (subCommand.requiresGeneratedXcconfigImport) {
+      validateGeneratedBuildSettings(platform);
+    }
     switch (subCommand) {
-      case 'build':
+      case XcodeBuildCommand.build:
         buildApp(platform, 'build');
-      case 'prepare':
+      case XcodeBuildCommand.prepare:
         unpackFor(platform, 'prepare');
-      case 'build-add-to-app':
+      case XcodeBuildCommand.buildAddToApp:
         buildForNativeApp(platform);
-      case 'thin':
+      case XcodeBuildCommand.thin:
         // No-op, thinning is handled during the bundle asset assemble build target.
         break;
-      case 'embed':
-      case 'embed_and_thin':
+      case XcodeBuildCommand.embed:
+      case XcodeBuildCommand.embedAndThin:
         // Thinning is handled during the bundle asset assemble build target, so just embed.
         embedFlutterFrameworks(platform);
-      case 'test_vm_service_bonjour_service':
+      case XcodeBuildCommand.testVmServiceBonjourService:
         // Exposed for integration testing only.
         addVmServiceBonjourService();
     }
   }
 
+  /// If any of these are missing from the environment, the Xcode project's
+  /// build configurations are not correctly including the generated xcconfig,
+  /// and the build would otherwise continue in a broken state (no app version,
+  /// dropped dart-defines, wrong target, etc).
+  static const List<String> requiredGeneratedBuildSettings = <String>[
+    'FLUTTER_ROOT',
+    'FLUTTER_BUILD_DIR',
+    'FLUTTER_BUILD_NAME',
+    'FLUTTER_BUILD_NUMBER',
+  ];
+
+  void validateGeneratedBuildSettings(TargetPlatform platform) {
+    final bool hasMissingSettings = requiredGeneratedBuildSettings.any(
+      (String setting) => environment[setting] == null,
+    );
+    if (!hasMissingSettings) {
+      return;
+    }
+    final includeDirective = platform == TargetPlatform.macos
+        ? '#include "ephemeral/Flutter-Generated.xcconfig"'
+        : '#include "Generated.xcconfig"';
+    echoXcodeError(
+      'Missing Flutter build settings. Run "flutter build ${platform.name} --config-only" '
+      'to regenerate the Flutter xcconfig files, and verify the build configuration for '
+      'the current scheme includes $includeDirective.',
+    );
+    exitApp(-1);
+  }
+
   /// Validates the command argument matches one of the possible commands.
   /// Returns null if not.
-  String validateCommand(String command) {
-    switch (command) {
-      case 'build':
-      case 'prepare':
-      case 'thin':
-      case 'embed':
-      case 'embed_and_thin':
-      case 'build-add-to-app':
-      case 'test_vm_service_bonjour_service':
-        return command;
-      default:
-        echoXcodeError(incompatibleErrorMessage);
-        exit(-1);
+  XcodeBuildCommand validateCommand(String command) {
+    final XcodeBuildCommand? parsedCommand = XcodeBuildCommand.tryParse(command);
+    if (parsedCommand == null) {
+      echoXcodeError(incompatibleErrorMessage);
+      exit(-1);
     }
+
+    return parsedCommand;
   }
 
   /// Converts the [platformName] argument to a [TargetPlatform]. If there is
diff --git a/packages/flutter_tools/test/general.shard/xcode_backend_test.dart b/packages/flutter_tools/test/general.shard/xcode_backend_test.dart
index b1153f5..5fe4419 100644
--- a/packages/flutter_tools/test/general.shard/xcode_backend_test.dart
+++ b/packages/flutter_tools/test/general.shard/xcode_backend_test.dart
@@ -30,6 +30,9 @@
         'BUILT_PRODUCTS_DIR': buildDir.path,
         'CONFIGURATION': buildMode,
         'FLUTTER_ROOT': flutterRoot.path,
+        'FLUTTER_BUILD_DIR': 'build',
+        'FLUTTER_BUILD_NAME': '1.0.0',
+        'FLUTTER_BUILD_NUMBER': '1',
         'INFOPLIST_PATH': 'Info.plist',
       },
       commands: <FakeCommand>[
@@ -81,6 +84,9 @@
             'ACTION': 'build',
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
           },
           commands: <FakeCommand>[],
@@ -105,6 +111,9 @@
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'CONFIGURATION': buildMode,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
           },
           commands: <FakeCommand>[
@@ -185,6 +194,9 @@
             'EXTRA_FRONT_END_OPTIONS': extraFrontEndOptions,
             'EXTRA_GEN_SNAPSHOT_OPTIONS': extraGenSnapshotOptions,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'FRONTEND_SERVER_STARTER_PATH': frontendServerStarterPath,
             'INFOPLIST_PATH': 'Info.plist',
             'SDKROOT': sdkRoot,
@@ -674,6 +686,9 @@
             'ACTION': 'build',
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
           },
           commands: <FakeCommand>[
@@ -725,6 +740,9 @@
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'CONFIGURATION': buildMode,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
           },
           commands: <FakeCommand>[
@@ -795,6 +813,9 @@
             'EXTRA_FRONT_END_OPTIONS': extraFrontEndOptions,
             'EXTRA_GEN_SNAPSHOT_OPTIONS': extraGenSnapshotOptions,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'FRONTEND_SERVER_STARTER_PATH': frontendServerStarterPath,
             'INFOPLIST_PATH': 'Info.plist',
             'SDKROOT': sdkRoot,
@@ -858,6 +879,9 @@
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'CONFIGURATION': buildMode,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
             'ARCHS': 'arm64 x86_64',
             'ONLY_ACTIVE_ARCH': 'YES',
@@ -912,6 +936,9 @@
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'CONFIGURATION': buildMode,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
             'ARCHS': 'arm64',
             'ONLY_ACTIVE_ARCH': 'YES',
@@ -966,6 +993,9 @@
             'BUILT_PRODUCTS_DIR': buildDir.path,
             'CONFIGURATION': buildMode,
             'FLUTTER_ROOT': flutterRoot.path,
+            'FLUTTER_BUILD_DIR': 'build',
+            'FLUTTER_BUILD_NAME': '1.0.0',
+            'FLUTTER_BUILD_NUMBER': '1',
             'INFOPLIST_PATH': 'Info.plist',
             'ARCHS': 'arm64 x86_64',
             'NATIVE_ARCH': 'arm64e',
@@ -1055,6 +1085,9 @@
           'SOURCE_ROOT': platformDirPath,
           'FLUTTER_APPLICATION_PATH': appPath,
           'FLUTTER_BUILD_DIR': 'build',
+          'FLUTTER_ROOT': '/path/to/flutter',
+          'FLUTTER_BUILD_NAME': '1.0.0',
+          'FLUTTER_BUILD_NUMBER': '1',
           'TARGET_BUILD_DIR': targetBuildDir.path,
           'FRAMEWORKS_FOLDER_PATH': frameworksFolderPath,
           'EXPANDED_CODE_SIGN_IDENTITY': '12312313',
@@ -1202,6 +1235,9 @@
           'SOURCE_ROOT': platformDirPath,
           'FLUTTER_APPLICATION_PATH': appPath,
           'FLUTTER_BUILD_DIR': 'build',
+          'FLUTTER_ROOT': '/path/to/flutter',
+          'FLUTTER_BUILD_NAME': '1.0.0',
+          'FLUTTER_BUILD_NUMBER': '1',
           'TARGET_BUILD_DIR': targetBuildDir.path,
           'FRAMEWORKS_FOLDER_PATH': frameworksFolderPath,
           'EXPANDED_CODE_SIGN_IDENTITY': codesignIdentity,
@@ -1317,6 +1353,43 @@
       expect(testContext.processManager.hasRemainingExpectations, isFalse);
     });
   });
+
+  group('validates generated build settings', () {
+    for (final platform in platforms) {
+      final String platformName = platform.name;
+      test('build for $platformName exits with actionable error when settings are missing', () {
+        final context = TestContext(
+          <String>['build', platformName],
+          <String, String>{'ACTION': 'build'},
+          commands: <FakeCommand>[],
+          fileSystem: fileSystem,
+        );
+        expect(() => context.run(), throwsException);
+        // The actionable fix leads the message, since Xcode only shows the
+        // first line of an error by default.
+        expect(
+          context.stderr,
+          contains(
+            'error: Missing Flutter build settings. Run "flutter build $platformName '
+            '--config-only" to regenerate the Flutter xcconfig files, and verify the '
+            'build configuration for the current scheme includes '
+            '${platform == TargetPlatform.macos ? '#include "ephemeral/Flutter-Generated.xcconfig"' : '#include "Generated.xcconfig"'}.',
+          ),
+        );
+      });
+    }
+
+    test('build exits with error when only some settings are missing', () {
+      final context = TestContext(
+        <String>['build', 'ios'],
+        <String, String>{'FLUTTER_ROOT': '/path/to/flutter', 'FLUTTER_BUILD_DIR': 'build'},
+        commands: <FakeCommand>[],
+        fileSystem: fileSystem,
+      );
+      expect(() => context.run(), throwsException);
+      expect(context.stderr, contains('error: Missing Flutter build settings.'));
+    });
+  });
 }
 
 class TestContext extends Context {
diff --git a/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart b/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart
index 6528d78..8719a12 100644
--- a/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart
+++ b/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart
@@ -289,7 +289,10 @@
                   'VERBOSE_SCRIPT_LOGGING': '1',
                   'FLUTTER_BUILD_MODE': 'release',
                   'ACTION': 'install',
+                  'FLUTTER_ROOT': flutterRoot,
                   'FLUTTER_BUILD_DIR': 'build',
+                  'FLUTTER_BUILD_NAME': '1.0.0',
+                  'FLUTTER_BUILD_NUMBER': '1',
                   // Skip bitcode stripping since we just checked that above.
                 },
               );
diff --git a/packages/flutter_tools/test/integration.shard/macos_assemble_test.dart b/packages/flutter_tools/test/integration.shard/macos_assemble_test.dart
index 9cf8458..d896aca 100644
--- a/packages/flutter_tools/test/integration.shard/macos_assemble_test.dart
+++ b/packages/flutter_tools/test/integration.shard/macos_assemble_test.dart
@@ -45,7 +45,13 @@
     final ProcessResult result = await Process.run(
       _kMacosAssemblePath,
       <String>[],
-      environment: <String, String>{'CONFIGURATION': 'Custom'},
+      environment: <String, String>{
+        'CONFIGURATION': 'Custom',
+        'FLUTTER_ROOT': '../..',
+        'FLUTTER_BUILD_DIR': 'build',
+        'FLUTTER_BUILD_NAME': '1.0.0',
+        'FLUTTER_BUILD_NUMBER': '1',
+      },
     );
     expect(result.stderr, contains('ERROR: Unknown FLUTTER_BUILD_MODE: custom.'));
     expect(
diff --git a/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart
index b27ba19..8a1cbc0 100644
--- a/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart
+++ b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart
@@ -15,11 +15,20 @@
 const xcodeBackendErrorHeader =
     '========================================================================';
 
+// Settings normally supplied by the Generated Flutter xcconfig
+const generatedBuildSettings = <String, String>{
+  'FLUTTER_ROOT': '../..',
+  'FLUTTER_BUILD_DIR': 'build',
+  'FLUTTER_BUILD_NAME': '1.0.0',
+  'FLUTTER_BUILD_NUMBER': '1',
+};
+
 // Acceptable $CONFIGURATION/$FLUTTER_BUILD_MODE values should be debug, profile, or release
-const unknownConfiguration = <String, String>{'CONFIGURATION': 'Custom'};
+const unknownConfiguration = <String, String>{...generatedBuildSettings, 'CONFIGURATION': 'Custom'};
 
 // $FLUTTER_BUILD_MODE will override $CONFIGURATION
 const unknownFlutterBuildMode = <String, String>{
+  ...generatedBuildSettings,
   'FLUTTER_BUILD_MODE': 'Custom',
   'CONFIGURATION': 'Debug',
 };