Add readlink -f flag to CocoaPods script to workaround Xcode 14.3 issue (#124079)

Cherry-pick https://github.com/flutter/flutter/pull/124062 onto stable.

CP request at https://github.com/flutter/flutter/issues/124081
diff --git a/packages/flutter_tools/lib/src/macos/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart
index a131de0..07cad54 100644
--- a/packages/flutter_tools/lib/src/macos/cocoapods.dart
+++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart
@@ -13,10 +13,12 @@
 import '../base/os.dart';
 import '../base/platform.dart';
 import '../base/process.dart';
+import '../base/project_migrator.dart';
 import '../base/version.dart';
 import '../build_info.dart';
 import '../cache.dart';
 import '../ios/xcodeproj.dart';
+import '../migrations/cocoapods_script_symlink.dart';
 import '../reporting/reporting.dart';
 import '../xcode_project.dart';
 
@@ -166,6 +168,13 @@
         throwToolExit('CocoaPods not installed or not in valid state.');
       }
       await _runPodInstall(xcodeProject, buildMode);
+
+      // This migrator works around a CocoaPods bug, and should be run after `pod install` is run.
+      final ProjectMigration postPodMigration = ProjectMigration(<ProjectMigrator>[
+        CocoaPodsScriptReadlink(xcodeProject, _xcodeProjectInterpreter, _logger),
+      ]);
+      postPodMigration.run();
+
       podsProcessed = true;
     }
     return podsProcessed;
diff --git a/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart b/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart
new file mode 100644
index 0000000..f0d90ca
--- /dev/null
+++ b/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart
@@ -0,0 +1,52 @@
+// 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 '../base/file_system.dart';
+import '../base/project_migrator.dart';
+import '../base/version.dart';
+import '../ios/xcodeproj.dart';
+import '../xcode_project.dart';
+
+// Xcode 14.3 changed the readlink symlink behavior to be relative from the script working directory, instead of the
+// relative path of the symlink. The -f flag returns the original "--canonicalize" behavior the CocoaPods script relies on.
+// This has been fixed upstream in CocoaPods, but migrate a copy of their workaround so users don't need to update.
+//
+// See https://github.com/flutter/flutter/issues/123890#issuecomment-1494825976.
+class CocoaPodsScriptReadlink extends ProjectMigrator {
+  CocoaPodsScriptReadlink(
+    XcodeBasedProject project,
+    XcodeProjectInterpreter xcodeProjectInterpreter,
+    super.logger,
+  )   : _podRunnerFrameworksScript = project.podRunnerFrameworksScript,
+        _xcodeProjectInterpreter = xcodeProjectInterpreter;
+
+  final File _podRunnerFrameworksScript;
+  final XcodeProjectInterpreter _xcodeProjectInterpreter;
+
+  @override
+  void migrate() {
+    if (!_podRunnerFrameworksScript.existsSync()) {
+      logger.printTrace('CocoaPods Pods-Runner-frameworks.sh script not found, skipping "readlink -f" workaround.');
+      return;
+    }
+
+    final Version? version = _xcodeProjectInterpreter.version;
+
+    // If Xcode not installed or less than 14.3 with readlink behavior change, skip this migration.
+    if (version == null || version < Version(14, 3, 0)) {
+      logger.printTrace('Detected Xcode version is $version, below 14.3, skipping "readlink -f" workaround.');
+      return;
+    }
+
+    processFileLines(_podRunnerFrameworksScript);
+  }
+
+  @override
+  String? migrateLine(String line) {
+    const String originalReadLinkLine = r'source="$(readlink "${source}")"';
+    const String replacementReadLinkLine = r'source="$(readlink -f "${source}")"';
+
+    return line.replaceAll(originalReadLinkLine, replacementReadLinkLine);
+  }
+}
diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart
index ca08f89..485be40 100644
--- a/packages/flutter_tools/lib/src/xcode_project.dart
+++ b/packages/flutter_tools/lib/src/xcode_project.dart
@@ -89,6 +89,13 @@
 
   /// The CocoaPods 'Manifest.lock'.
   File get podManifestLock => hostAppRoot.childDirectory('Pods').childFile('Manifest.lock');
+
+  /// The CocoaPods generated 'Pods-Runner-frameworks.sh'.
+  File get podRunnerFrameworksScript => hostAppRoot
+      .childDirectory('Pods')
+      .childDirectory('Target Support Files')
+      .childDirectory('Pods-Runner')
+      .childFile('Pods-Runner-frameworks.sh');
 }
 
 /// Represents the iOS sub-project of a Flutter project.
diff --git a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart
index 6326e43..beb40cb 100644
--- a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart
+++ b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart
@@ -6,6 +6,7 @@
 import 'package:file/memory.dart';
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/project_migrator.dart';
+import 'package:flutter_tools/src/base/version.dart';
 import 'package:flutter_tools/src/ios/migrations/host_app_info_plist_migration.dart';
 import 'package:flutter_tools/src/ios/migrations/ios_deployment_target_migration.dart';
 import 'package:flutter_tools/src/ios/migrations/project_base_configuration_migration.dart';
@@ -13,6 +14,8 @@
 import 'package:flutter_tools/src/ios/migrations/remove_bitcode_migration.dart';
 import 'package:flutter_tools/src/ios/migrations/remove_framework_link_and_embedding_migration.dart';
 import 'package:flutter_tools/src/ios/migrations/xcode_build_system_migration.dart';
+import 'package:flutter_tools/src/ios/xcodeproj.dart';
+import 'package:flutter_tools/src/migrations/cocoapods_script_symlink.dart';
 import 'package:flutter_tools/src/migrations/xcode_project_object_version_migration.dart';
 import 'package:flutter_tools/src/migrations/xcode_script_build_phase_migration.dart';
 import 'package:flutter_tools/src/reporting/reporting.dart';
@@ -20,6 +23,7 @@
 import 'package:test/fake.dart';
 
 import '../../src/common.dart';
+import '../../src/fake_process_manager.dart';
 
 void main () {
   group('iOS migration', () {
@@ -900,6 +904,104 @@
         expect('Disabling deprecated bitcode Xcode build setting'.allMatches(testLogger.warningText).length, 1);
       });
     });
+
+    group('CocoaPods script readlink', () {
+      late MemoryFileSystem memoryFileSystem;
+      late BufferLogger testLogger;
+      late FakeIosProject project;
+      late File podRunnerFrameworksScript;
+      late ProcessManager processManager;
+      late XcodeProjectInterpreter xcode143ProjectInterpreter;
+
+      setUp(() {
+        memoryFileSystem = MemoryFileSystem();
+        podRunnerFrameworksScript = memoryFileSystem.file('Pods-Runner-frameworks.sh');
+        testLogger = BufferLogger.test();
+        project = FakeIosProject();
+        processManager = FakeProcessManager.any();
+        xcode143ProjectInterpreter = XcodeProjectInterpreter.test(processManager: processManager, version: Version(14, 3, 0));
+        project.podRunnerFrameworksScript = podRunnerFrameworksScript;
+      });
+
+      testWithoutContext('skipped if files are missing', () {
+        final CocoaPodsScriptReadlink iosProjectMigration = CocoaPodsScriptReadlink(
+          project,
+          xcode143ProjectInterpreter,
+          testLogger,
+        );
+        iosProjectMigration.migrate();
+        expect(podRunnerFrameworksScript.existsSync(), isFalse);
+
+        expect(testLogger.traceText, contains('CocoaPods Pods-Runner-frameworks.sh script not found'));
+        expect(testLogger.statusText, isEmpty);
+      });
+
+      testWithoutContext('skipped if nothing to upgrade', () {
+        const String contents = r'''
+  if [ -L "${source}" ]; then
+    echo "Symlinked..."
+    source="$(readlink -f "${source}")"
+  fi''';
+        podRunnerFrameworksScript.writeAsStringSync(contents);
+
+        final CocoaPodsScriptReadlink iosProjectMigration = CocoaPodsScriptReadlink(
+          project,
+          xcode143ProjectInterpreter,
+          testLogger,
+        );
+        iosProjectMigration.migrate();
+        expect(podRunnerFrameworksScript.existsSync(), isTrue);
+        expect(testLogger.traceText, isEmpty);
+        expect(testLogger.statusText, isEmpty);
+      });
+
+      testWithoutContext('skipped if Xcode version below 14.3', () {
+        const String contents = r'''
+  if [ -L "${source}" ]; then
+    echo "Symlinked..."
+    source="$(readlink "${source}")"
+  fi''';
+        podRunnerFrameworksScript.writeAsStringSync(contents);
+
+        final XcodeProjectInterpreter xcode142ProjectInterpreter = XcodeProjectInterpreter.test(
+          processManager: processManager,
+          version: Version(14, 2, 0),
+        );
+
+        final CocoaPodsScriptReadlink iosProjectMigration = CocoaPodsScriptReadlink(
+          project,
+          xcode142ProjectInterpreter,
+          testLogger,
+        );
+        iosProjectMigration.migrate();
+        expect(podRunnerFrameworksScript.existsSync(), isTrue);
+        expect(testLogger.traceText, contains('Detected Xcode version is 14.2.0, below 14.3, skipping "readlink -f" workaround'));
+        expect(testLogger.statusText, isEmpty);
+      });
+
+      testWithoutContext('Xcode project is migrated', () {
+        const String contents = r'''
+  if [ -L "${source}" ]; then
+    echo "Symlinked..."
+    source="$(readlink "${source}")"
+  fi''';
+        podRunnerFrameworksScript.writeAsStringSync(contents);
+
+        final CocoaPodsScriptReadlink iosProjectMigration = CocoaPodsScriptReadlink(
+          project,
+          xcode143ProjectInterpreter,
+          testLogger,
+        );
+        iosProjectMigration.migrate();
+        expect(podRunnerFrameworksScript.readAsStringSync(), r'''
+  if [ -L "${source}" ]; then
+    echo "Symlinked..."
+    source="$(readlink -f "${source}")"
+  fi
+''');
+        expect(testLogger.statusText, contains('Upgrading Pods-Runner-frameworks.sh'));
+      });
+    });
   });
 
   group('update Xcode script build phase', () {
@@ -1025,6 +1127,9 @@
 
   @override
   File podfile = MemoryFileSystem.test().file('Podfile');
+
+  @override
+  File podRunnerFrameworksScript = MemoryFileSystem.test().file('podRunnerFrameworksScript');
 }
 
 class FakeIOSMigrator extends ProjectMigrator {
diff --git a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
index 3c34f14..a03134e 100644
--- a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
+++ b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart
@@ -6,6 +6,7 @@
 import 'package:file/memory.dart';
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/platform.dart';
+import 'package:flutter_tools/src/base/version.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/cache.dart';
 import 'package:flutter_tools/src/flutter_plugins.dart';
@@ -729,6 +730,55 @@
       );
       expect(didInstall, isTrue);
       expect(fakeProcessManager, hasNoRemainingExpectations);
+      expect(logger.traceText, contains('CocoaPods Pods-Runner-frameworks.sh script not found'));
+    });
+
+    testUsingContext('runs CocoaPods Pod runner script migrator', () async {
+      final FlutterProject projectUnderTest = setupProjectUnderTest();
+      pretendPodIsInstalled();
+      pretendPodVersionIs('100.0.0');
+      projectUnderTest.ios.podfile
+        ..createSync()
+        ..writeAsStringSync('Existing Podfile');
+      projectUnderTest.ios.podfileLock
+        ..createSync()
+        ..writeAsStringSync('Existing lock file.');
+      projectUnderTest.ios.podManifestLock
+        ..createSync(recursive: true)
+        ..writeAsStringSync('Existing lock file.');
+      projectUnderTest.ios.podRunnerFrameworksScript
+        ..createSync(recursive: true)
+        ..writeAsStringSync(r'source="$(readlink "${source}")"');
+
+      fakeProcessManager.addCommands(const <FakeCommand>[
+        FakeCommand(
+          command: <String>['pod', 'install', '--verbose'],
+          workingDirectory: 'project/ios',
+          environment: <String, String>{'COCOAPODS_DISABLE_STATS': 'true', 'LANG': 'en_US.UTF-8'},
+        ),
+        FakeCommand(
+          command: <String>['touch', 'project/ios/Podfile.lock'],
+        ),
+      ]);
+
+      final CocoaPods cocoaPodsUnderTestXcode143 = CocoaPods(
+        fileSystem: fileSystem,
+        processManager: fakeProcessManager,
+        logger: logger,
+        platform: FakePlatform(operatingSystem: 'macos'),
+        xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: fakeProcessManager, version: Version(14, 3, 0)),
+        usage: usage,
+      );
+
+      final bool didInstall = await cocoaPodsUnderTestXcode143.processPods(
+        xcodeProject: projectUnderTest.ios,
+        buildMode: BuildMode.debug,
+      );
+      expect(didInstall, isTrue);
+      expect(fakeProcessManager, hasNoRemainingExpectations);
+      // Now has readlink -f flag.
+      expect(projectUnderTest.ios.podRunnerFrameworksScript.readAsStringSync(), contains(r'source="$(readlink -f "${source}")"'));
+      expect(logger.statusText, contains('Upgrading Pods-Runner-frameworks.sh'));
     });
 
     testUsingContext('runs pod install, if Podfile.lock is older than Podfile', () async {