Repository: beeware/briefcase

Issue:
"Build app (ppb, macos-latest)" CI job is broken
This is related to #1475:
```
Installing app requirements... done
Collecting wrapt==1.15.0
  Downloading wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl (36 kB)
Installing collected packages: wrapt
Successfully installed wrapt-1.15.0
Installing binary app requirements for arm64... done
Merging app_packages.x86_64... done
Merging app_packages.arm64... done
Merging libraries...
error: /Applications/Xcode_14.2.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo: can't move temporary file: /Users/runner/work/briefcase/briefcase/tests/apps/verify-ppb/build/verify-ppb/macos/app/Hello PPB.app/Contents/Resources/app_packages/wrapt/_wrappers.cpython-310-darwin.so to file: /Users/runner/work/briefcase/briefcase/tests/apps/verify-ppb/build/verify-ppb/macos/app/Hello PPB.app/Contents/Resources/app_packages/wrapt/_wrappers.cpython-310-darwin.so.lipo (No such file or directory)

Unable to create fat library for wrapt/_wrappers.cpython-310-darwin.so
```

Discussion:

Patch:
diff --git a/changes/1481.misc.rst b/changes/1481.misc.rst
new file mode 100644
index 00000000..82633072
--- /dev/null
+++ b/changes/1481.misc.rst
@@ -0,0 +1,1 @@
+macOS dynamic libraries are now inspected to ensure that they are single platform before merging, and they are only merged once.
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 9f97ac7d..f1c7716c 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -95,7 +95,22 @@ class macOSInstallMixin(AppPackagesMergeMixin):
                     },
                 )
         else:
-            self.logger.info("All packages are pure python or universal.")
+            self.logger.info("All packages are pure Python, or universal.")
+
+        # If given the option of a single architecture binary or a universal2 binary,
+        # pip will install the single platform binary. However, a common situation on
+        # macOS is for there to be an x86_64 binary and a universal2 binary. This means
+        # you only get a universal2 binary in the "other" install pass. This then causes
+        # problems with merging, because the "other" binary contains a copy of the
+        # architecture that the "host" platform provides.
+        #
+        # To avoid this - ensure that the libraries in the app packages for the "other"
+        # arch are all thin.
+        #
+        # This doesn't matter if it happens the other way around - if the "host" arch
+        # installs a universal binary, then the "other" arch won't be asked to install
+        # a binary at all.
+        self.thin_app_packages(other_app_packages_path, arch=other_arch)
 
         # Merge the binaries
         self.merge_app_packages(
diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py
index 43730fbe..2e993a42 100644
--- a/src/briefcase/platforms/macOS/utils.py
+++ b/src/briefcase/platforms/macOS/utils.py
@@ -67,6 +67,61 @@ class AppPackagesMergeMixin:
 
         return binary_packages
 
+    def ensure_thin_dylib(self, path: Path, arch: str):
+        """Ensure that a library is thin, targeting a given architecture.
+
+        If the library is already thin, it is left as-is.
+
+        :param path: The library file to process.
+        :param arch: The architecture that should be preserved.
+        """
+        try:
+            output = self.tools.subprocess.check_output(
+                ["lipo", "-info", path],
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError(
+                f"Unable to inspect architectures in {path}"
+            ) from e
+        else:
+            if output.startswith("Non-fat file: "):
+                if self.logger.verbosity >= 1:
+                    self.logger.info(f"{path} is already thin.")
+            elif output.startswith("Architectures in the fat file: "):
+                architectures = set(output.strip().split(":")[-1].strip().split(" "))
+                if arch in architectures:
+                    if self.logger.verbosity >= 1:
+                        self.logger.info(f"Thinning {path}")
+                    try:
+                        thin_lib_path = path.parent / f"{path.name}.{arch}"
+                        self.tools.subprocess.run(
+                            [
+                                "lipo",
+                                "-thin",
+                                arch,
+                                "-output",
+                                thin_lib_path,
+                                path,
+                            ],
+                            check=True,
+                        )
+                    except subprocess.CalledProcessError as e:
+                        raise BriefcaseCommandError(
+                            f"Unable to create thin library from {path}"
+                        ) from e
+                    else:
+                        # Having extracted the single architecture into a temporary
+                        # file, replace the original with the thin version.
+                        self.tools.shutil.move(thin_lib_path, path)
+                else:
+                    raise BriefcaseCommandError(
+                        f"{path} does not contain a {arch} slice."
+                    )
+            else:
+                raise BriefcaseCommandError(
+                    f"Unable to determine architectures in {path}"
+                )
+
     def lipo_dylib(self, relative_path: Path, target_path: Path, sources: list[Path]):
         """Create a fat library by invoking lipo on multiple source libraries.
 
@@ -105,6 +160,40 @@ class AppPackagesMergeMixin:
                 f"Unable to create fat library for {relative_path}"
             ) from e
 
+    def thin_app_packages(
+        self,
+        app_packages: Path,
+        arch: str,
+    ):
+        """Ensure that all the dylibs in a given app_packages folder are thin."""
+        dylibs = []
+        for source_path in app_packages.glob("**/*"):
+            if source_path.suffix in {".so", ".dylib"}:
+                dylibs.append(source_path)
+
+        # Call lipo on each dylib that was found to ensure it is thin.
+        if dylibs:
+            # Do this in a threadpool to make it run faster.
+            progress_bar = self.input.progress_bar()
+            self.logger.info(f"Thinning libraries in {app_packages.name}...")
+            task_id = progress_bar.add_task("Create fat libraries", total=len(dylibs))
+            with progress_bar:
+                with concurrent.futures.ThreadPoolExecutor() as executor:
+                    futures = []
+                    for path in dylibs:
+                        future = executor.submit(
+                            self.ensure_thin_dylib,
+                            path=path,
+                            arch=arch,
+                        )
+                        futures.append(future)
+                    for future in concurrent.futures.as_completed(futures):
+                        progress_bar.update(task_id, advance=1)
+                        if future.exception():
+                            raise future.exception()
+        else:
+            self.logger.info("No libraries require thinning.")
+
     def merge_app_packages(
         self,
         target_app_packages: Path,
@@ -127,7 +216,7 @@ class AppPackagesMergeMixin:
         # different, but they'll be purged later anyway. Don't warn for anything in a
         # .dist-info folder either; these are going to be different because of platform
         # difference, but core package metadata should be consistent.
-        dylibs = []
+        dylibs = set()
         digests = {}
         for source_app_packages in sources:
             with self.input.wait_bar(f"Merging {source_app_packages.name}..."):
@@ -139,7 +228,7 @@ class AppPackagesMergeMixin:
                     else:
                         if source_path.suffix in {".so", ".dylib"}:
                             # Dynamic libraries need to be merged; do this in a second pass.
-                            dylibs.append(relative_path)
+                            dylibs.add(relative_path)
                         elif target_path.exists():
                             # The file already exists. Check for differences; if there are any
                             # differences outside `dist-info` or `__pycache__` folders, warn


Test patch:
diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py
index 22a8904b..d01ccd5d 100644
--- a/tests/platforms/linux/system/test_package__pkg.py
+++ b/tests/platforms/linux/system/test_package__pkg.py
@@ -31,8 +31,15 @@ def package_command(first_app, tmp_path):
     # Mock the app context
     command.tools.app_tools[first_app].app_context = mock.MagicMock()
 
-    # Mock shutil move and rmtree
-    command.tools.shutil.move = mock.MagicMock()
+    # Mock shutil
+    command.tools.shutil = mock.MagicMock()
+
+    # Make the mock copy still copy
+    command.tools.shutil.copy = mock.MagicMock(side_effect=shutil.copy)
+
+    # Make the mock make_archive still package tarballs
+    command.tools.shutil.make_archive = mock.MagicMock(side_effect=shutil.make_archive)
+
     # Make the mock rmtree still remove content
     command.tools.shutil.rmtree = mock.MagicMock(side_effect=shutil.rmtree)
 
diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py
index cf7f1ea5..b1cb7cba 100644
--- a/tests/platforms/linux/system/test_package__rpm.py
+++ b/tests/platforms/linux/system/test_package__rpm.py
@@ -31,8 +31,12 @@ def package_command(first_app, tmp_path):
     # Mock the app context
     command.tools.app_tools[first_app].app_context = mock.MagicMock()
 
-    # Mock shutil move and rmtree
-    command.tools.shutil.move = mock.MagicMock()
+    # Mock shutil
+    command.tools.shutil = mock.MagicMock()
+
+    # Make the mock make_archive still package tarballs
+    command.tools.shutil.make_archive = mock.MagicMock(side_effect=shutil.make_archive)
+
     # Make the mock rmtree still remove content
     command.tools.shutil.rmtree = mock.MagicMock(side_effect=shutil.rmtree)
 
diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py
index a58b84fd..d200aa3c 100644
--- a/tests/platforms/macOS/app/test_create.py
+++ b/tests/platforms/macOS/app/test_create.py
@@ -55,6 +55,10 @@ def test_install_app_packages(
             ("third", "3.4.5"),
         ]
     )
+
+    # Mock the thin command so we can confirm it was invoked.
+    create_command.thin_app_packages = mock.Mock()
+
     # Mock the merge command so we can confirm it was invoked.
     create_command.merge_app_packages = mock.Mock()
 
@@ -123,6 +127,12 @@ def test_install_app_packages(
     # versions is validated as a result of the underlying install/merge methods.
     assert (bundle_path / f"app_packages.{other_arch}").is_dir()
 
+    # An attempt was made thin the "other" arch packages.
+    create_command.thin_app_packages.assert_called_once_with(
+        bundle_path / f"app_packages.{other_arch}",
+        arch=other_arch,
+    )
+
     # An attempt was made to merge packages.
     create_command.merge_app_packages.assert_called_once_with(
         target_app_packages=bundle_path
@@ -163,6 +173,9 @@ def test_install_app_packages_no_binary(
     # Mock the result of finding no binary packages.
     create_command.find_binary_packages = mock.Mock(return_value=[])
 
+    # Mock the thin command so we can confirm it was invoked.
+    create_command.thin_app_packages = mock.Mock()
+
     # Mock the merge command so we can confirm it was invoked.
     create_command.merge_app_packages = mock.Mock()
 
@@ -205,7 +218,11 @@ def test_install_app_packages_no_binary(
     # result of the underlying install/merge methods.
     assert (bundle_path / f"app_packages.{other_arch}").is_dir()
 
-    # We still need to merge the app packages; this is effectively just a copy.
+    # We still need to thin and merge the app packages; this is effectively just a copy.
+    create_command.thin_app_packages.assert_called_once_with(
+        bundle_path / f"app_packages.{other_arch}",
+        arch=other_arch,
+    )
     create_command.merge_app_packages.assert_called_once_with(
         target_app_packages=bundle_path
         / "First App.app"
@@ -238,6 +255,9 @@ def test_install_app_packages_failure(create_command, first_app_templated, tmp_p
         ]
     )
 
+    # Mock the thin command so we can confirm it was invoked.
+    create_command.thin_app_packages = mock.Mock()
+
     # Mock the merge command so we can confirm it was invoked.
     create_command.merge_app_packages = mock.Mock()
 
@@ -323,7 +343,8 @@ def test_install_app_packages_failure(create_command, first_app_templated, tmp_p
     # result of the underlying install/merge methods.
     assert (bundle_path / "app_packages.x86_64").is_dir()
 
-    # We didn't attempt to merge, because we didn't complete installing.
+    # We didn't attempt to thin or  merge, because we didn't complete installing.
+    create_command.thin_app_packages.assert_not_called()
     create_command.merge_app_packages.assert_not_called()
 
 
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py
new file mode 100644
index 00000000..a344b1f1
--- /dev/null
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__ensure_thin_dylib.py
@@ -0,0 +1,269 @@
+import subprocess
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ...utils import create_file, file_content
+
+
+@pytest.mark.parametrize("debug", [True, False])
+def test_thin_dylib(dummy_command, tmp_path, debug, capsys):
+    """A thin binary library is left as-is."""
+    if debug:
+        dummy_command.logger.verbosity = 1
+
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-original")
+
+    # Mock the result of the "lipo info" call.
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Non-fat file: path/to/file.dylib is architecture: gothic\n"
+    )
+
+    # Thin the library; this is effectively a no-op
+    dummy_command.ensure_thin_dylib(
+        tmp_path / "path" / "to" / "file.dylib",
+        arch="gothic",
+    )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was *not* invoked
+    dummy_command.tools.subprocess.run.assert_not_called()
+
+    # The original file is unmodified.
+    assert file_content(tmp_path / "path" / "to" / "file.dylib") == "dylib-original"
+
+    # Output only happens if in debug mode
+    output = capsys.readouterr().out.split("\n")
+    assert len(output) == (2 if debug else 1)
+
+
+@pytest.mark.parametrize("debug", [True, False])
+def test_fat_dylib(dummy_command, tmp_path, debug, capsys):
+    """A fat binary library can be thinned."""
+    if debug:
+        dummy_command.logger.verbosity = 1
+
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
+
+    # Mock the result of the "lipo info" call.
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Architectures in the fat file: path/to/file.dylib are: modern gothic\n"
+    )
+
+    # Mock the result of successfully thinning a library
+    def thin_dylib(*args, **kwargs):
+        create_file(args[0][args[0].index("-output") + 1], "dylib-thin")
+
+    dummy_command.tools.subprocess.run.side_effect = thin_dylib
+
+    # Thin the library to the "gothic" architecture
+    dummy_command.ensure_thin_dylib(
+        tmp_path / "path" / "to" / "file.dylib",
+        arch="gothic",
+    )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was invoked
+    dummy_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "lipo",
+            "-thin",
+            "gothic",
+            "-output",
+            tmp_path / "path" / "to" / "file.dylib.gothic",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+        check=True,
+    )
+
+    # The original file now has the thinned content.
+    assert file_content(tmp_path / "path" / "to" / "file.dylib") == "dylib-thin"
+
+    # Output only happens if in debug mode
+    output = capsys.readouterr().out.split("\n")
+    assert len(output) == (2 if debug else 1)
+
+
+@pytest.mark.parametrize("debug", [True, False])
+def test_fat_dylib_arch_mismatch(dummy_command, tmp_path, debug, capsys):
+    """If a fat binary doesn't contain the target architecture, an error is raised."""
+    if debug:
+        dummy_command.logger.verbosity = 1
+
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
+
+    # Mock the result of the "lipo info" call.
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Architectures in the fat file: path/to/file.dylib are: modern artdeco\n"
+    )
+
+    # Thin the library to the "gothic" architecture. This will raise an exception
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"file\.dylib does not contain a gothic slice",
+    ):
+        dummy_command.ensure_thin_dylib(
+            tmp_path / "path" / "to" / "file.dylib",
+            arch="gothic",
+        )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was *not* invoked
+    dummy_command.tools.subprocess.run.assert_not_called()
+
+
+@pytest.mark.parametrize("debug", [True, False])
+def test_fat_dylib_unknown_info(dummy_command, tmp_path, debug, capsys):
+    """If the lipo info call succeeds, but generates unknown output, an error is
+    raised."""
+    if debug:
+        dummy_command.logger.verbosity = 1
+
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
+
+    # Mock the result of the "lipo info" call.
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "This is unexpected output...\n"
+    )
+
+    # Thin the library to the "gothic" architecture. This will raise an exception
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to determine architectures in .*file\.dylib",
+    ):
+        dummy_command.ensure_thin_dylib(
+            tmp_path / "path" / "to" / "file.dylib",
+            arch="gothic",
+        )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was *not* invoked
+    dummy_command.tools.subprocess.run.assert_not_called()
+
+
+def test_lipo_info_fail(dummy_command, tmp_path):
+    """If lipo can't inspect a binary, an error is raised."""
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
+
+    # Mock the result of the "lipo info" call.
+    dummy_command.tools.subprocess.check_output.side_effect = (
+        subprocess.CalledProcessError(cmd="lipo -info", returncode=-1)
+    )
+
+    # Thin the library to the "gothic" architecture. This will raise an exception
+    with pytest.raises(
+        BriefcaseCommandError, match=r"Unable to inspect architectures in .*file\.dylib"
+    ):
+        dummy_command.ensure_thin_dylib(
+            tmp_path / "path" / "to" / "file.dylib",
+            arch="gothic",
+        )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was not invoked
+    dummy_command.tools.subprocess.run.assert_not_called()
+
+
+@pytest.mark.parametrize("debug", [True, False])
+def test_lipo_thin_fail(dummy_command, tmp_path, debug, capsys):
+    """If lipo fails thinning the binary, an error is raised."""
+    if debug:
+        dummy_command.logger.verbosity = 1
+
+    # Create a source binary.
+    create_file(tmp_path / "path" / "to" / "file.dylib", "dylib-fat")
+
+    # Mock the result of the "lipo -info" call.
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Architectures in the fat file: path/to/file.dylib are: modern gothic\n"
+    )
+
+    # Mock the result of the failed "lipo -thin" call.
+    dummy_command.tools.subprocess.run.side_effect = subprocess.CalledProcessError(
+        cmd="lipo -thin", returncode=-1
+    )
+
+    # Thin the library to the "gothic" architecture. This will raise an exception
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to create thin library from .*file.dylib",
+    ):
+        dummy_command.ensure_thin_dylib(
+            tmp_path / "path" / "to" / "file.dylib",
+            arch="gothic",
+        )
+
+    # Lipo -info was invoked
+    dummy_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "lipo",
+            "-info",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+    )
+
+    # Lipo -thin was invoked
+    dummy_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "lipo",
+            "-thin",
+            "gothic",
+            "-output",
+            tmp_path / "path" / "to" / "file.dylib.gothic",
+            tmp_path / "path" / "to" / "file.dylib",
+        ],
+        check=True,
+    )
+
+    # The original file is unmodified.
+    assert file_content(tmp_path / "path" / "to" / "file.dylib") == "dylib-fat"
+
+    # Output only happens if in debug mode
+    output = capsys.readouterr().out.split("\n")
+    assert len(output) == (2 if debug else 1)
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py
new file mode 100644
index 00000000..26143fcf
--- /dev/null
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__thin_app_packages.py
@@ -0,0 +1,107 @@
+import subprocess
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ...utils import create_file, create_installed_package, file_content
+
+
+def test_thin_app_packages(dummy_command, tmp_path):
+    "An app packages folder can be thinned"
+    app_packages = tmp_path / "app_pacakges.gothic"
+
+    # Install a package into the app_packages folder that contains source and binaries.
+    create_installed_package(
+        app_packages,
+        "pkg",
+        "2.3.4",
+        tag="macOS_11_0_gothic",
+        extra_content=[
+            ("pkg/other.py", "# other python"),
+            ("pkg/sub1/module1.dylib", "dylib-fat"),
+            ("pkg/sub1/module2.so", "dylib-fat"),
+            ("pkg/sub2/module3.dylib", "dylib-fat"),
+        ],
+    )
+
+    # All dylibs have 2 architectures
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Architectures in the fat file: path/to/file.dylib are: modern gothic\n"
+    )
+
+    # Mock the effect of calling lipo -thin
+    def thin_dylib(*args, **kwargs):
+        create_file(args[0][args[0].index("-output") + 1], "dylib-thin")
+
+    dummy_command.tools.subprocess.run.side_effect = thin_dylib
+
+    # Thin the app_packages folder to gothic dylibs
+    dummy_command.thin_app_packages(app_packages, arch="gothic")
+
+    # All libraries have been thinned
+    assert file_content(app_packages / "pkg" / "sub1" / "module1.dylib") == "dylib-thin"
+    assert file_content(app_packages / "pkg" / "sub1" / "module2.so") == "dylib-thin"
+    assert file_content(app_packages / "pkg" / "sub2" / "module3.dylib") == "dylib-thin"
+
+
+def test_thin_app_packages_problem(dummy_command, tmp_path):
+    "If one of the libraries can't be thinned, an error is raised"
+    app_packages = tmp_path / "app_pacakges.gothic"
+
+    # Install a package into the app_packages folder that contains source and binaries.
+    create_installed_package(
+        app_packages,
+        "pkg",
+        "2.3.4",
+        tag="macOS_11_0_gothic",
+        extra_content=[
+            ("pkg/other.py", "# other python"),
+            ("pkg/sub1/module1.dylib", "dylib-fat"),
+            ("pkg/sub1/module2.so", "dylib-fat"),
+            ("pkg/sub2/module3.dylib", "dylib-fat"),
+        ],
+    )
+
+    # All dylibs have 2 architectures
+    dummy_command.tools.subprocess.check_output.return_value = (
+        "Architectures in the fat file: path/to/file.dylib are: modern gothic\n"
+    )
+
+    # Mock the effect of calling lipo -thin. Calling on a .so file raises an error.
+    def thin_dylib(*args, **kwargs):
+        if str(args[0][-1]).endswith(".so"):
+            raise subprocess.CalledProcessError(cmd="lipo -thin", returncode=-1)
+        create_file(args[0][args[0].index("-output") + 1], "dylib-thin")
+
+    dummy_command.tools.subprocess.run.side_effect = thin_dylib
+
+    # Thin the app_packages folder to gothic dylibs. This raises an error:
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to create thin library from .*module2\.so",
+    ):
+        dummy_command.thin_app_packages(app_packages, arch="gothic")
+
+
+def test_thin_no_dylibs(dummy_command, tmp_path):
+    "If there are no dylibs, thinning is a no-op."
+    app_packages = tmp_path / "app_pacakges.gothic"
+
+    # Install a package into the app_packages folder that only contains source.
+    create_installed_package(
+        app_packages,
+        "pkg",
+        "2.3.4",
+        tag="macOS_11_0_gothic",
+        extra_content=[
+            ("pkg/other.py", "# other python"),
+        ],
+    )
+
+    # Thin the app packages folder
+    dummy_command.thin_app_packages(app_packages, arch="gothic")
+
+    # lipo was not called.
+    dummy_command.tools.subprocess.check_output.assert_not_called()
+    dummy_command.tools.subprocess.run.assert_not_called()