Repository: pypa/cibuildwheel

Issue:
Min macOS version
### Description

There are two changes in b2; the free-threaded one is something we already knew about, but also the macOS version has finally been bumped from 10.9 to 10.13. This breaks up the question: how should we deal with it? For the least change, we can manually force MACOSX_DEPLOYMENT_TARGET to 10.13 if we detect it set less than 10.13, including the default 10.9. The other option is we could set the minimum across the board to 10.13, but users can set it lower (except for Python 3.13+, where we'd still need the above clamping). Thoughts? 10.13 brings nearly complete C++17 support (completed in 10.14, stared in 10.12).

 (Not at https://www.python.org/api/v2/downloads/release/?is_published=true yet, guessing it's a bit too soon to show up (https://www.python.org/api/v2/downloads/release/?is_published=true&name=Python%203.13.0b2 is empty)? Not listed on python.org yet unless you know where to look - https://www.python.org/downloads/release/python-3130b2/)

Followup to #1831.

### Build log

_No response_

### CI config

_No response_


Discussion:

Resolving patch:
diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py
--- a/cibuildwheel/macos.py
+++ b/cibuildwheel/macos.py
@@ -34,6 +34,7 @@
     detect_ci_provider,
     download,
     find_compatible_wheel,
+    free_thread_enable_313,
     get_build_verbosity_extra_flags,
     get_pip_version,
     install_certifi_script,
@@ -115,12 +116,13 @@ def get_python_configurations(
     return python_configurations
 
 
-def install_cpython(tmp: Path, version: str, url: str) -> Path:
-    installation_path = Path(f"/Library/Frameworks/Python.framework/Versions/{version}")
+def install_cpython(tmp: Path, version: str, url: str, free_threading: bool) -> Path:
+    ft = "T" if free_threading else ""
+    installation_path = Path(f"/Library/Frameworks/Python{ft}.framework/Versions/{version}")
     with FileLock(CIBW_CACHE_PATH / f"cpython{version}.lock"):
         installed_system_packages = call("pkgutil", "--pkgs", capture_stdout=True).splitlines()
         # if this version of python isn't installed, get it from python.org and install
-        python_package_identifier = f"org.python.Python.PythonFramework-{version}"
+        python_package_identifier = f"org.python.Python.Python{ft}Framework-{version}"
         if python_package_identifier not in installed_system_packages:
             if detect_ci_provider() is None:
                 # if running locally, we don't want to install CPython with sudo
@@ -137,13 +139,22 @@ def install_cpython(tmp: Path, version: str, url: str) -> Path:
             # download the pkg
             download(url, pkg_path)
             # install
-            call("sudo", "installer", "-pkg", pkg_path, "-target", "/")
+            args = []
+            if version.startswith("3.13"):
+                # Python 3.13 is the first version to have a free-threading option
+                args += ["-applyChoiceChangesXML", str(free_thread_enable_313.resolve())]
+            call("sudo", "installer", "-pkg", pkg_path, *args, "-target", "/")
             pkg_path.unlink()
             env = os.environ.copy()
             env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
-            call(installation_path / "bin" / "python3", install_certifi_script, env=env)
 
-    return installation_path / "bin" / "python3"
+            if free_threading:
+                call(installation_path / f"bin/python{version}t", "-m", "ensurepip", env=env)
+                call(installation_path / f"bin/python{version}t", install_certifi_script, env=env)
+            else:
+                call(installation_path / "bin/python3", install_certifi_script, env=env)
+
+    return installation_path / "bin" / (f"python{version}t" if free_threading else "python3")
 
 
 def install_pypy(tmp: Path, url: str) -> Path:
@@ -172,13 +183,19 @@ def setup_python(
     implementation_id = python_configuration.identifier.split("-")[0]
     log.step(f"Installing Python {implementation_id}...")
     if implementation_id.startswith("cp"):
-        base_python = install_cpython(tmp, python_configuration.version, python_configuration.url)
+        free_threading = "t-macos" in python_configuration.identifier
+        base_python = install_cpython(
+            tmp, python_configuration.version, python_configuration.url, free_threading
+        )
+
     elif implementation_id.startswith("pp"):
         base_python = install_pypy(tmp, python_configuration.url)
     else:
         msg = "Unknown Python implementation"
         raise ValueError(msg)
-    assert base_python.exists()
+    assert (
+        base_python.exists()
+    ), f"{base_python.name} not found, has {list(base_python.parent.iterdir())}"
 
     log.step("Setting up build environment...")
     venv_path = tmp / "venv"
@@ -244,8 +261,25 @@ def setup_python(
     # Set MACOSX_DEPLOYMENT_TARGET, if the user didn't set it.
     # For arm64, the minimal deployment target is 11.0.
     # On x86_64 (or universal2), use 10.9 as a default.
-    # PyPy defaults to 10.7, causing inconsistencies if it's left unset.
-    env.setdefault("MACOSX_DEPLOYMENT_TARGET", "11.0" if config_is_arm64 else "10.9")
+    # CPython 3.13 needs 10.13.
+    if config_is_arm64:
+        default_target = "11.0"
+    elif Version(python_configuration.version) >= Version("3.13"):
+        default_target = "10.13"
+    elif python_configuration.identifier.startswith("pp") and Version(
+        python_configuration.version
+    ) >= Version("3.9"):
+        default_target = "10.15"
+    else:
+        default_target = "10.9"
+    env.setdefault("MACOSX_DEPLOYMENT_TARGET", default_target)
+
+    # This is a floor, it can't be set lower than the default_target.
+    if Version(env["MACOSX_DEPLOYMENT_TARGET"]) < Version(default_target):
+        log.warning(
+            f"Bumping MACOSX_DEPLOYMENT_TARGET ({env['MACOSX_DEPLOYMENT_TARGET']}) to the minimum required ({default_target})."
+        )
+        env["MACOSX_DEPLOYMENT_TARGET"] = default_target
 
     if python_configuration.version not in {"3.6", "3.7"}:
         if config_is_arm64:
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -59,6 +59,8 @@
 
 install_certifi_script: Final[Path] = resources_dir / "install_certifi.py"
 
+free_thread_enable_313: Final[Path] = resources_dir / "free-threaded-enable-313.xml"
+
 test_fail_cwd_file: Final[Path] = resources_dir / "testing_temp_dir_file.py"
 
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -82,6 +82,8 @@ jobs:
         output-dir: wheelhouse
       env:
         CIBW_ARCHS_MACOS: x86_64 universal2 arm64
+        CIBW_FREE_THREADED_SUPPORT: 1
+        CIBW_PRERELEASE_PYTHONS: 1
 
     - name: Run a sample build (GitHub Action, only)
       uses: ./
diff --git a/test/test_macos_archs.py b/test/test_macos_archs.py
--- a/test/test_macos_archs.py
+++ b/test/test_macos_archs.py
@@ -14,7 +14,7 @@
     *utils.expected_wheels("spam", "0.1.0", machine_arch="arm64", include_universal2=True),
 }
 
-DEPLOYMENT_TARGET_TOO_LOW_WARNING = "[WARNING] MACOSX_DEPLOYMENT_TARGET is set to a lower value"
+DEPLOYMENT_TARGET_TOO_LOW_WARNING = "Bumping MACOSX_DEPLOYMENT_TARGET"
 
 
 def get_xcode_version() -> tuple[int, int]:
diff --git a/test/utils.py b/test/utils.py
--- a/test/utils.py
+++ b/test/utils.py
@@ -137,17 +137,11 @@ def cibuildwheel_run(
     return wheels
 
 
-def _get_arm64_macosx_deployment_target(macosx_deployment_target: str) -> str:
+def _floor_macosx(*args: str) -> str:
     """
-    The first version of macOS that supports arm is 11.0. So the wheel tag
-    cannot contain an earlier deployment target, even if
-    MACOSX_DEPLOYMENT_TARGET sets it.
+    Make sure a deployment target is not less than some value.
     """
-    version_tuple = tuple(map(int, macosx_deployment_target.split(".")))
-    if version_tuple <= (11, 0):
-        return "11.0"
-    else:
-        return macosx_deployment_target
+    return max(args, key=lambda x: tuple(map(int, x.split("."))))
 
 
 def expected_wheels(
@@ -202,9 +196,8 @@ def expected_wheels(
             "cp311-cp311",
             "cp312-cp312",
             "cp313-cp313",
+            "cp313-cp313t",
         ]
-        if platform != "macos":
-            python_abi_tags.append("cp313-cp313t")
 
         if machine_arch in ["x86_64", "AMD64", "x86", "aarch64"]:
             python_abi_tags += [
@@ -223,6 +216,7 @@ def expected_wheels(
                 "cp311-cp311",
                 "cp312-cp312",
                 "cp313-cp313",
+                "cp313-cp313t",
                 "pp38-pypy38_pp73",
                 "pp39-pypy39_pp73",
                 "pp310-pypy310_pp73",
@@ -282,12 +276,19 @@ def expected_wheels(
 
         elif platform == "macos":
             if machine_arch == "arm64":
-                arm64_macosx_deployment_target = _get_arm64_macosx_deployment_target(
-                    macosx_deployment_target
-                )
-                platform_tags = [f'macosx_{arm64_macosx_deployment_target.replace(".", "_")}_arm64']
+                arm64_macosx = _floor_macosx(macosx_deployment_target, "11.0")
+                platform_tags = [f'macosx_{arm64_macosx.replace(".", "_")}_arm64']
             else:
-                platform_tags = [f'macosx_{macosx_deployment_target.replace(".", "_")}_x86_64']
+                if python_abi_tag.startswith("pp") and not python_abi_tag.startswith(
+                    ("pp37", "pp38")
+                ):
+                    pypy_macosx = _floor_macosx(macosx_deployment_target, "10.15")
+                    platform_tags = [f'macosx_{pypy_macosx.replace(".", "_")}_x86_64']
+                elif python_abi_tag.startswith("cp313"):
+                    pypy_macosx = _floor_macosx(macosx_deployment_target, "10.13")
+                    platform_tags = [f'macosx_{pypy_macosx.replace(".", "_")}_x86_64']
+                else:
+                    platform_tags = [f'macosx_{macosx_deployment_target.replace(".", "_")}_x86_64']
 
             if include_universal2:
                 platform_tags.append(