From 45fb46f5cd7ccbabe969085dddd8752b8f46ffe2 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 23 Sep 2026 18:46:17 +0200 Subject: [PATCH 1/5] fix(darwin): re-sign linker-signed macOS native modules while staging Python extensions from python-build (the stdlib lib-dynload modules) and from many wheels keep the ad-hoc signature the linker attached, whose identifier is the file name including `.so`. Xcode's distribution signing re-signs them with --preserve-metadata=identifier, which ignores linker signatures, so codesign derives a new identifier without the extension, while Xcode can write the designated requirement from the old one. The result, reported with Xcode 26.5, is a signature that fails its own requirement, and App Store Connect rejects the build with 90238 "does not satisfy its designated Requirement" on every such file. prepare_macos.sh (stdlib) and sync_site_packages.sh (site-packages, app) now give every still-linker-signed .so/.dylib a regular ad-hoc signature, whose identifier re-signing preserves. Only the staged dist_macos copies are touched; the source directories and the provider xcframeworks are left as they are. CocoaPods and SwiftPM both build from dist_macos. Refs #250 --- .../darwin/linker_signatures.sh | 50 +++++++++++++++++++ .../darwin/prepare_macos.sh | 6 +++ .../darwin/sync_site_packages.sh | 6 +++ 3 files changed, 62 insertions(+) create mode 100644 src/serious_python_darwin/darwin/linker_signatures.sh diff --git a/src/serious_python_darwin/darwin/linker_signatures.sh b/src/serious_python_darwin/darwin/linker_signatures.sh new file mode 100644 index 00000000..ffd10911 --- /dev/null +++ b/src/serious_python_darwin/darwin/linker_signatures.sh @@ -0,0 +1,50 @@ +# Signature normalization for the Mach-O libraries bundled into a macOS app. +# +# replace_linker_signatures ... +# +# Re-signs ad-hoc every `.so`, `.so.*` and `.dylib` under the given directories +# that carries the signature the linker attached when it built the file. +# +# A linker signature names the code after the output file, extension included +# (`_ssl.cpython-314-darwin.so`), and codesign never carries metadata over from +# one: re-signing the file derives a fresh identifier from the file name minus +# its extension (`_ssl.cpython-314-darwin`). Xcode's distribution signing +# (Organizer, `xcodebuild -exportArchive`) can write the designated requirement +# from the identifier it read off the existing signature, so a linker-signed +# library ends up with a requirement its own new signature does not satisfy, +# and App Store Connect rejects the upload (error 90238, "does not satisfy its +# designated Requirement"). A regular ad-hoc signature's identifier is carried +# over by re-signing, so the requirement and the signature agree. +# +# Files are inspected one at a time, because `codesign -d` given several paths +# stops at the first one that is not signed. Anything that is not +# linker-signed -- already re-signed, unsigned, or not Mach-O at all -- is left +# untouched, so a repeated run only re-checks. Ad-hoc signing is deterministic, +# so re-signing a fresh copy of the same file reproduces the same bytes. Paths +# containing newlines are not supported. +# +# Returns non-zero, after printing the error, when a directory cannot be listed +# or a file cannot be re-signed. +replace_linker_signatures() { + local dir files bin err + for dir in "$@"; do + [ -d "$dir" ] || continue + if ! files=$(find "$dir" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \)); then + echo "replace_linker_signatures: cannot list $dir" >&2 + return 1 + fi + while IFS= read -r bin; do + [ -n "$bin" ] || continue + case $(codesign -d --verbose=1 "$bin" 2>&1) in + *"CodeDirectory "*linker-signed*) ;; + *) continue ;; + esac + if ! err=$(codesign --force --sign - "$bin" 2>&1); then + echo "replace_linker_signatures: codesign failed for $bin: $err" >&2 + return 1 + fi + done </dev/null)" != "$pb_id" ]; then echo "$pb_id" > "$marker" fi +# ---- stdlib native module signatures ---------------------------------------- +# Outside the extraction guard, so an already-extracted dist is covered as well. +# The provider xcframeworks are not touched. +. "$script_dir/linker_signatures.sh" +replace_linker_signatures "$dist/stdlib" || exit 1 + # ---- flet-dev/dart-bridge (xcframework, same archive for macOS + iOS) ----- # Separate cache guard so a stale $dist from before this change still picks # up the new artifact on first re-prepare. diff --git a/src/serious_python_darwin/darwin/sync_site_packages.sh b/src/serious_python_darwin/darwin/sync_site_packages.sh index ed065943..e98e6d6d 100755 --- a/src/serious_python_darwin/darwin/sync_site_packages.sh +++ b/src/serious_python_darwin/darwin/sync_site_packages.sh @@ -3,6 +3,8 @@ script_dir=$(cd "$(dirname "$0")" && pwd -P) # Provider-signature + provider-integrity checks (see xcframework_verify.sh). # Sourced unconditionally so both the iOS and macOS branches can use it. source $script_dir/xcframework_verify.sh +# Linker-signature replacement for the macOS resource trees. +source "$script_dir/linker_signatures.sh" # App sources are arch- and platform-independent; stage them as a bare `app/` # resource bundle into BOTH dist trees, regardless of whether site-packages @@ -16,6 +18,7 @@ if [[ -n "$SERIOUS_PYTHON_APP" && -d "$SERIOUS_PYTHON_APP" ]]; then mkdir -p "$app_dist/app" rsync -a --exclude '.pod' "$SERIOUS_PYTHON_APP/" "$app_dist/app/" done + replace_linker_signatures "$script_dir/dist_macos/app" || exit 1 fi if [[ -n "$SERIOUS_PYTHON_SITE_PACKAGES" && -d "$SERIOUS_PYTHON_SITE_PACKAGES" ]]; then @@ -136,6 +139,9 @@ if [[ -n "$SERIOUS_PYTHON_SITE_PACKAGES" && -d "$SERIOUS_PYTHON_SITE_PACKAGES" ] # file. .pod is only needed by package_command.dart at packaging # time to invoke this sync script; it does not belong in the bundle. rsync -av --delete --exclude '.pod' "$SERIOUS_PYTHON_SITE_PACKAGES/" "$dist/site-packages/" + # The staged copy is re-signed, never the source; each sync restores the + # linker-signed files and re-signing them reproduces the same bytes. + replace_linker_signatures "$dist/site-packages" || exit 1 # macOS has no framework-ization step -- its .so's load flat from the # resource tree -- so nothing here should ever touch the provider From cf73ced2eaf00ea4f0318552700936be8979eaf3 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 23 Sep 2026 18:46:17 +0200 Subject: [PATCH 2/5] ci: fail the macOS example build on linker-signed bundled libraries After the integration tests, check every .so/.dylib under the built app's Contents/Resources and fail if any still carries a linker signature, on both the CocoaPods and SwiftPM legs. --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64512d60..81f6d2e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,31 @@ jobs: flutter test integration_test/throughput_test.dart -d macos --dart-define=EXPECTED_PYTHON_VERSION=${{ matrix.python_version }} flutter test integration_test/memory_test.dart -d macos --dart-define=EXPECTED_PYTHON_VERSION=${{ matrix.python_version }} + - name: Check bundled native libraries are not linker-signed + working-directory: "src/serious_python/example/bridge_example" + run: | + # Xcode distribution signing mis-signs linker-signed libraries (see + # src/serious_python_darwin/darwin/linker_signatures.sh), so none may + # reach the app bundle. + app=$(find build/macos/Build/Products -maxdepth 2 -type d -name '*.app' | head -n 1) + if [ -z "$app" ]; then + echo "::error::No built app under build/macos/Build/Products" + exit 1 + fi + total=0 + linker_signed=0 + while IFS= read -r bin; do + total=$((total + 1)) + case $(codesign -d --verbose=1 "$bin" 2>&1) in + *"CodeDirectory "*linker-signed*) + echo "::error::Linker-signed: ${bin#"$app"/}" + linker_signed=$((linker_signed + 1)) + ;; + esac + done < <(find "$app/Contents/Resources" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \)) + echo "Checked $total bundled native libraries, $linker_signed linker-signed." + [ "$total" -gt 0 ] && [ "$linker_signed" -eq 0 ] + bridge_example_ios: name: Test Bridge example on iOS (${{ matrix.build_system }}, Python ${{ matrix.python_version }}) if: ${{ !startsWith(github.ref, 'refs/tags/') }} From 1b1f192a3ecfe4e13f97c993374597d8bea2a1a7 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 23 Sep 2026 18:46:17 +0200 Subject: [PATCH 3/5] docs: macOS native module signatures in README and 4.7.2 changelogs --- src/serious_python/CHANGELOG.md | 4 ++++ src/serious_python/README.md | 14 ++++++++++++++ src/serious_python_darwin/CHANGELOG.md | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/src/serious_python/CHANGELOG.md b/src/serious_python/CHANGELOG.md index 80b9d3ab..0972f431 100644 --- a/src/serious_python/CHANGELOG.md +++ b/src/serious_python/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.7.2 + +* **macOS:** fix App Store Connect rejecting Xcode-distributed builds with `90238: Invalid signature … does not satisfy its designated Requirement` on bundled `.so` files ([#250](https://github.com/flet-dev/serious-python/issues/250)). Linker-signed native modules are re-signed ad-hoc while they are staged, so Xcode's distribution signing gives them valid signatures. See `serious_python_darwin` 4.7.2. + ## 4.7.1 * Fix macOS crashes during native scientific imports and NumPy operations by giving the asynchronous Python worker at least **8 MiB** of stack space, via `dart_bridge` 1.10.0. ([dart-bridge#21](https://github.com/flet-dev/dart-bridge/pull/21), [#85](https://github.com/flet-dev/serious-python/issues/85)) diff --git a/src/serious_python/README.md b/src/serious_python/README.md index 9609d010..95e661cc 100644 --- a/src/serious_python/README.md +++ b/src/serious_python/README.md @@ -319,6 +319,20 @@ produces a working app, but it cannot produce complete SDK-origin receipts. **Us the SwiftPM path for App Store submissions** until that path is replaced with real vendored XCFramework declarations. +#### macOS native module signatures + +On macOS, the native modules under `stdlib/`, `site-packages/` and `app/` ship as +plain `.so`/`.dylib` files, which Xcode's distribution signing (the Organizer or +`xcodebuild -exportArchive`) re-signs with your certificate along with the rest of +the app. A module carrying the signature the linker gave it comes out of that +step with a designated requirement naming a different identifier than its new +signature: codesign does not carry a linker signature's identifier over, but Xcode +can build the requirement from it. App Store Connect rejects such an upload with +error 90238 ("does not satisfy its designated Requirement"). The macOS build +therefore replaces linker signatures with regular ad-hoc ones while staging these +trees. The `SERIOUS_PYTHON_SITE_PACKAGES` and `SERIOUS_PYTHON_APP` directories +themselves are left unchanged. + ### Linux / Windows specifics The CPython runtime (`libpython3.so` + `libpython.so` on Linux; `python3.dll` + `python.dll` on Windows), `libdart_bridge`, the stdlib, and native modules are copied next to your app's executable at build time. `PYTHONHOME` is the executable's directory. On Windows, extension modules (`.pyd`) and their dependent DLLs live in `/DLLs/`, which is added to `sys.path`. diff --git a/src/serious_python_darwin/CHANGELOG.md b/src/serious_python_darwin/CHANGELOG.md index 81d6dd41..0b9bec1d 100644 --- a/src/serious_python_darwin/CHANGELOG.md +++ b/src/serious_python_darwin/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.7.2 + +* **macOS: bundled native modules pass App Store Connect's signature check after Xcode distribution signing** ([#250](https://github.com/flet-dev/serious-python/issues/250)). The stdlib `lib-dynload` modules and many wheel extensions (e.g. Pillow's `_imagingmorph`) arrive with the linker's ad-hoc signature, whose identifier is the file name including `.so`. When Xcode re-signs them for distribution (Organizer, `xcodebuild -exportArchive`), `codesign` cannot carry that identifier over and derives one without the extension, while Xcode can write the designated requirement from the old one, so App Store Connect rejected the upload with `90238: Invalid signature … does not satisfy its designated Requirement`. The macOS staging now re-signs every still-linker-signed `.so`/`.dylib` ad-hoc: `prepare_macos.sh` for the stdlib, `sync_site_packages.sh` for site-packages and the app. A regular ad-hoc signature's identifier survives re-signing, so the requirement and the signature agree. `SERIOUS_PYTHON_SITE_PACKAGES`, `SERIOUS_PYTHON_APP` and the provider XCFrameworks are not modified. CI now fails the macOS example build if a linker-signed library reaches the app bundle. + ## 4.7.1 * Fix macOS crashes during native scientific imports and NumPy operations by giving the asynchronous Python worker at least **8 MiB** of stack space, via `dart_bridge` 1.10.0. ([dart-bridge#21](https://github.com/flet-dev/dart-bridge/pull/21), [#85](https://github.com/flet-dev/serious-python/issues/85)) From dafa10d2b575ba1b3c395ea5c479cbb80b68b2c0 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 24 Sep 2026 09:51:46 +0200 Subject: [PATCH 4/5] fix(darwin): fail pod install when a prepare script fails The podspec ran symlink_pod.sh, prepare_ios.sh, prepare_macos.sh and sync_site_packages.sh as newline-separated commands in Ruby backticks and never checked their exit status. A script's `exit 1` was printed and then ignored, and the build went on with a partially prepared runtime. This covered a native module that could not be re-signed, a provider manifest mismatch, and a provider signature check in `require` mode, which the README documents as failing the build. The prepare commands start with `set -e` so the first failing script stops the sequence, and the podspec raises on a non-zero status, so `pod install` and the Flutter build running it fail with the error. prepare_spm.sh, used by the Swift Package Manager path, already stops at the first failure. --- src/serious_python/CHANGELOG.md | 3 ++- src/serious_python_darwin/CHANGELOG.md | 3 ++- src/serious_python_darwin/darwin/serious_python_darwin.podspec | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/serious_python/CHANGELOG.md b/src/serious_python/CHANGELOG.md index 0972f431..d09e85fd 100644 --- a/src/serious_python/CHANGELOG.md +++ b/src/serious_python/CHANGELOG.md @@ -1,6 +1,7 @@ ## 4.7.2 -* **macOS:** fix App Store Connect rejecting Xcode-distributed builds with `90238: Invalid signature … does not satisfy its designated Requirement` on bundled `.so` files ([#250](https://github.com/flet-dev/serious-python/issues/250)). Linker-signed native modules are re-signed ad-hoc while they are staged, so Xcode's distribution signing gives them valid signatures. See `serious_python_darwin` 4.7.2. +* **macOS:** fix App Store Connect error `90238` ("does not satisfy its designated Requirement") for bundled native modules after Xcode distribution signing ([#250](https://github.com/flet-dev/serious-python/issues/250)). See `serious_python_darwin` 4.7.2. +* **iOS/macOS (CocoaPods):** fail `pod install` when a Python runtime preparation script fails, preventing builds from continuing with an incomplete runtime. See `serious_python_darwin` 4.7.2. ## 4.7.1 diff --git a/src/serious_python_darwin/CHANGELOG.md b/src/serious_python_darwin/CHANGELOG.md index 0b9bec1d..76a0b2b9 100644 --- a/src/serious_python_darwin/CHANGELOG.md +++ b/src/serious_python_darwin/CHANGELOG.md @@ -1,6 +1,7 @@ ## 4.7.2 -* **macOS: bundled native modules pass App Store Connect's signature check after Xcode distribution signing** ([#250](https://github.com/flet-dev/serious-python/issues/250)). The stdlib `lib-dynload` modules and many wheel extensions (e.g. Pillow's `_imagingmorph`) arrive with the linker's ad-hoc signature, whose identifier is the file name including `.so`. When Xcode re-signs them for distribution (Organizer, `xcodebuild -exportArchive`), `codesign` cannot carry that identifier over and derives one without the extension, while Xcode can write the designated requirement from the old one, so App Store Connect rejected the upload with `90238: Invalid signature … does not satisfy its designated Requirement`. The macOS staging now re-signs every still-linker-signed `.so`/`.dylib` ad-hoc: `prepare_macos.sh` for the stdlib, `sync_site_packages.sh` for site-packages and the app. A regular ad-hoc signature's identifier survives re-signing, so the requirement and the signature agree. `SERIOUS_PYTHON_SITE_PACKAGES`, `SERIOUS_PYTHON_APP` and the provider XCFrameworks are not modified. CI now fails the macOS example build if a linker-signed library reaches the app bundle. +* **macOS:** fix App Store Connect error `90238` ("does not satisfy its designated Requirement") caused by linker-signed native modules ([#250](https://github.com/flet-dev/serious-python/issues/250)). Staging now replaces linker signatures on `.so` and `.dylib` files in the stdlib, site-packages and app with regular ad-hoc signatures. This preserves their signing identifiers when Xcode re-signs them for distribution, avoiding a mismatch with their designated requirements. +* **iOS/macOS (CocoaPods):** `pod install` now stops when a Python runtime preparation script exits with an error. Previously, the podspec ignored these failures, allowing builds to continue despite native module signing errors, provider integrity mismatches, or provider signature verification failures in `require` mode. ## 4.7.1 diff --git a/src/serious_python_darwin/darwin/serious_python_darwin.podspec b/src/serious_python_darwin/darwin/serious_python_darwin.podspec index 447fd213..207c32c2 100644 --- a/src/serious_python_darwin/darwin/serious_python_darwin.podspec +++ b/src/serious_python_darwin/darwin/serious_python_darwin.podspec @@ -55,6 +55,7 @@ Pod::Spec.new do |s| dist_macos = "dist_macos" prepare_command = <<-CMD + set -e ./symlink_pod.sh ./prepare_ios.sh #{python_version} #{python_full_version} #{python_build_date} #{dart_bridge_version} ./prepare_macos.sh #{python_version} #{python_full_version} #{python_build_date} #{dart_bridge_version} @@ -62,6 +63,7 @@ Pod::Spec.new do |s| CMD puts `#{prepare_command}` +raise "serious_python_darwin: preparing the Python runtime failed (exit status #{$?.exitstatus}); see the errors above" unless $?.success? # iOS frameworks s.ios.script_phase = { From 9f3fcf89a13cbb539e687f013e7f5eac393fea0e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 24 Sep 2026 10:06:26 +0200 Subject: [PATCH 5/5] fix(darwin): check every architecture slice for a linker signature replace_linker_signatures asked `codesign -d` about a single slice, the one codesign reports by default for the build machine. The linker signs arm64 slices and leaves x86_64 slices unsigned unless linked with -adhoc_codesign, so python-build's universal stdlib modules carry a linker-signed arm64 slice next to an unsigned x86_64 one. On an Intel Mac the helper saw only the unsigned slice and skipped every such file, and a linker-signed x86_64 slice next to an ad-hoc arm64 one was missed on any machine. Each slice listed by `lipo -archs` is checked with `codesign -d --architecture`, and a file with any linker-signed slice is re-signed as a whole. `codesign --verify` cannot serve as a shortcut because it accepts linker signatures. The CI check inspects every slice the same way and names the offending one. --- .github/workflows/ci.yml | 15 ++++--- .../darwin/linker_signatures.sh | 42 ++++++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81f6d2e6..68b70064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,12 +153,15 @@ jobs: linker_signed=0 while IFS= read -r bin; do total=$((total + 1)) - case $(codesign -d --verbose=1 "$bin" 2>&1) in - *"CodeDirectory "*linker-signed*) - echo "::error::Linker-signed: ${bin#"$app"/}" - linker_signed=$((linker_signed + 1)) - ;; - esac + for arch in $(lipo -archs "$bin" 2>/dev/null); do + case $(codesign -d --verbose=1 --architecture "$arch" "$bin" 2>&1) in + *"CodeDirectory "*linker-signed*) + echo "::error::Linker-signed ($arch slice): ${bin#"$app"/}" + linker_signed=$((linker_signed + 1)) + break + ;; + esac + done done < <(find "$app/Contents/Resources" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \)) echo "Checked $total bundled native libraries, $linker_signed linker-signed." [ "$total" -gt 0 ] && [ "$linker_signed" -eq 0 ] diff --git a/src/serious_python_darwin/darwin/linker_signatures.sh b/src/serious_python_darwin/darwin/linker_signatures.sh index ffd10911..08647204 100644 --- a/src/serious_python_darwin/darwin/linker_signatures.sh +++ b/src/serious_python_darwin/darwin/linker_signatures.sh @@ -3,7 +3,8 @@ # replace_linker_signatures ... # # Re-signs ad-hoc every `.so`, `.so.*` and `.dylib` under the given directories -# that carries the signature the linker attached when it built the file. +# that has an architecture slice carrying the signature the linker attached +# when it built the file. # # A linker signature names the code after the output file, extension included # (`_ssl.cpython-314-darwin.so`), and codesign never carries metadata over from @@ -16,17 +17,22 @@ # designated Requirement"). A regular ad-hoc signature's identifier is carried # over by re-signing, so the requirement and the signature agree. # -# Files are inspected one at a time, because `codesign -d` given several paths -# stops at the first one that is not signed. Anything that is not -# linker-signed -- already re-signed, unsigned, or not Mach-O at all -- is left -# untouched, so a repeated run only re-checks. Ad-hoc signing is deterministic, -# so re-signing a fresh copy of the same file reproduces the same bytes. Paths -# containing newlines are not supported. +# Every slice of a universal file is inspected, one file at a time: `codesign +# -d` reports a single slice unless `--architecture` selects one, which slice +# that is depends on the build machine, and given several paths it stops at the +# first one that is not signed. The linker signs arm64 slices and usually +# leaves x86_64 slices unsigned, so the linker signature is often on a slice +# other than the reported one. `codesign --verify` accepts linker signatures, +# so it cannot tell them apart. A file with any linker-signed slice is +# re-signed, all slices at once. Anything else -- already re-signed, unsigned, +# or not Mach-O at all -- is left untouched, so a repeated run only re-checks. +# Ad-hoc signing is deterministic, so re-signing a fresh copy of the same file +# reproduces the same bytes. Paths containing newlines are not supported. # # Returns non-zero, after printing the error, when a directory cannot be listed # or a file cannot be re-signed. replace_linker_signatures() { - local dir files bin err + local dir files bin archs arch err for dir in "$@"; do [ -d "$dir" ] || continue if ! files=$(find "$dir" -type f \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \)); then @@ -35,14 +41,18 @@ replace_linker_signatures() { fi while IFS= read -r bin; do [ -n "$bin" ] || continue - case $(codesign -d --verbose=1 "$bin" 2>&1) in - *"CodeDirectory "*linker-signed*) ;; - *) continue ;; - esac - if ! err=$(codesign --force --sign - "$bin" 2>&1); then - echo "replace_linker_signatures: codesign failed for $bin: $err" >&2 - return 1 - fi + archs=$(lipo -archs "$bin" 2>/dev/null) || continue + for arch in $archs; do + case $(codesign -d --verbose=1 --architecture "$arch" "$bin" 2>&1) in + *"CodeDirectory "*linker-signed*) ;; + *) continue ;; + esac + if ! err=$(codesign --force --sign - "$bin" 2>&1); then + echo "replace_linker_signatures: codesign failed for $bin: $err" >&2 + return 1 + fi + break + done done <