Skip to content

Commit 7d064ec

Browse files
Provider-sign every published Apple XCFramework; dart_bridge 1.7.0 (#37)
* Provider-sign every published Apple XCFramework Xcode records the state of each .xcframework an app links against AS ITS PUBLISHER SHIPPED IT and writes that into the IPA as Signatures/<name>.xcframework-<platform>.signature. Everything we publish was unsigned, so those receipts read `signed = false` / `isSecureTimestamp = false` -- which is what Apple's App Store scan reports as ITMS-91065: Missing signature (flet-dev/flet#6724). The app's own signature does not fill this in: Xcode re-signs the embedded copy with the submitting team's identity, and the SDK-origin receipt is a separate record. Stable provider-owned identifiers (darwin/xcframework_identifiers.sh) A framework's CFBundleIdentifier becomes its code-signing identifier and is baked in here, long before anyone knows which app will embed it. It must therefore not depend on the consuming app -- serious_python used to rewrite it downstream, which edits an Info.plist inside a signed bundle and destroys the very signature the receipt reports on. The Python runtime is now dev.flet.python.runtime and each stdlib extension dev.flet.python.<module>, replacing the shared org.python.*. Leading hyphens are kept (`_ssl` -> `dev.flet.python.-ssl`), matching CPython's own convention, so `_ssl` cannot collide with a hypothetical `ssl`. Validation asserts every identifier is syntactically valid, unique across the set, identical across device and simulator slices, and provider-prefixed. Signing (darwin/xcframework_signing.sh, darwin/sign_darwin_archives.sh) Signing must be the last thing that happens to a bundle and it needs a release certificate. Doing it on the finished archives satisfies both: by the time an archive exists, every mutation -- install names, plists, privacy manifests, header overlays, pruning, stripping, bytecode compilation -- is done, and the certificate never has to be present in the build matrix that runs on every push and PR. Per archive: extract, validate identifiers, sign every outer xcframework and verify each immediately, re-pack, then extract the re-packed archive into a fresh directory and verify again. The re-pack preserves the original member-path shape ("./install/..." vs "install/...") because consumers extract with --wildcards patterns that match one and not the other. The outer seal is stamped with an explicit `-i`, read off the xcframework's own inner framework: an .xcframework root Info.plist is an XFWK manifest with no CFBundleIdentifier, so codesign would otherwise fall back to the bundle's file name. Verification asserts an outer _CodeSignature/CodeResources, `codesign --verify --strict`, a non-ad-hoc signature, a real Timestamp= (not the self-asserted Signed Time=), the expected authority and TeamIdentifier, and that the seal's identifier matches the inner framework's. No --deep, no --timestamp=none. package-ios-for-dart.sh additionally asserts the _ssl and _hashlib privacy manifests survived into both slices -- a silent miss there surfaces as an App Store rejection, not a build error. CI sign-darwin-artifacts is a release-only job in the protected `release-signing` environment, gated on a workflow_dispatch with a release_date from main. It imports the p12 into a temporary keychain without -A (only codesign and security may reach the key), derives exactly one identity fingerprint and fails on any other count, and deletes the keychain in an always() step. The unsigned build artifact is renamed darwin-unsigned-* rather than python-darwin-*, specifically so no `python-*` download pattern in publish-release can reach it. publish-release now downloads Android/Linux/Windows by explicit pattern plus the python-darwin-signed bundle, is gated on main like the signing job, and fails if the expected Darwin tarballs are absent. The mobile-forge Python.xcframework gets its identifier immediately after build_ios.py finishes -- that script strips and rewrites inside the bundle, so it is the first moment the artifact is final. Local builds without credentials still work and produce unsigned artifacts. REQUIRE_XCFRAMEWORK_SIGNATURE=1, set on the release path, turns every missing credential, missing timestamp, wrong team, or empty archive into a hard failure. * Bump dart bridge version to 1.7.0 1.7.0 is the first dart_bridge release whose dart_bridge.xcframework carries a provider signature with a secure timestamp, sealed under dev.flet.dartbridge -- the counterpart of what this branch does for the Python XCFrameworks. Both halves have to move together, or the IPA still reports one of them as unsigned. Version-keyed consumer caches hold the unsigned 1.6.1 zip, so this must be a version bump rather than a re-release. Compiled binaries are unchanged from 1.6.1.
1 parent 00085b0 commit 7d064ec

10 files changed

Lines changed: 859 additions & 15 deletions

.github/workflows/build-python-version.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ jobs:
5858
# iOS/README.rst, 3.12 uses beeware. Emits normalized ./install + ./support.
5959
python build_ios.py "$PYTHON_VERSION"
6060
61+
# build_ios.py mutates the CPython-created Python.xcframework after assembly
62+
# (sysconfigdata rewrites, `strip -x` of every slice binary), so this is the
63+
# first moment the bundle is final. Give it its stable provider identifier
64+
# here — the release signing job seals it, and a plist edit afterwards would
65+
# invalidate that seal.
66+
bash ./xcframework_identifiers.sh set \
67+
"support/$PYTHON_VERSION_SHORT/iOS/Python.xcframework" dev.flet.python.runtime
68+
6169
# mobile-forge artifact: iOS-only install+support tree (same structure as before).
6270
# Captured before the macOS build also writes into ./support / ./install.
6371
tar -czf dist/python-ios-mobile-forge-$PYTHON_VERSION.tar.gz install support
@@ -73,10 +81,15 @@ jobs:
7381
bash ./package-ios-for-dart.sh . "$PYTHON_VERSION"
7482
bash ./package-macos-for-dart.sh . "$PYTHON_VERSION"
7583
76-
- name: Upload Darwin build artifacts
84+
# Deliberately NOT named `python-darwin-*`: the XCFrameworks in these
85+
# tarballs are unsigned, and the publish job selects release assets with
86+
# `python-*` patterns. Keeping the unsigned Darwin build outside that
87+
# namespace makes it structurally impossible for it to be published by
88+
# accident — sign-darwin-artifacts re-emits them as `python-darwin-signed`.
89+
- name: Upload Darwin build artifacts (unsigned)
7790
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
7891
with:
79-
name: python-darwin-${{ env.PYTHON_VERSION }}
92+
name: darwin-unsigned-${{ env.PYTHON_VERSION }}
8093
path: darwin/dist/python-*.tar.gz
8194
if-no-files-found: error
8295

.github/workflows/build-python.yml

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,134 @@ jobs:
5252
with:
5353
python_version: ${{ matrix.python_version }}
5454

55+
# Release-only, isolated signing job. The provider certificate is never
56+
# available to build-matrix (which runs on every push and PR); it lives in the
57+
# protected `release-signing` environment and is only reachable from an
58+
# explicit release dispatch on main.
59+
#
60+
# Signing operates on the finished archives rather than inside the build, which
61+
# guarantees the required ordering: every mutation (install names, plists,
62+
# privacy manifests, headers, pruning, stripping) is already done by the time
63+
# an archive exists. The script re-packs and re-verifies after a round trip.
64+
sign-darwin-artifacts:
65+
name: Provider-sign Darwin XCFrameworks
66+
runs-on: macos-26
67+
if: >-
68+
github.event_name == 'workflow_dispatch'
69+
&& inputs.release_date != ''
70+
&& github.ref == 'refs/heads/main'
71+
needs:
72+
- build-matrix
73+
environment: release-signing
74+
env:
75+
XCFRAMEWORK_EXPECTED_TEAM_ID: ${{ vars.XCFRAMEWORK_EXPECTED_TEAM_ID }}
76+
# Missing credentials, a missing secure timestamp, a wrong team, or an
77+
# archive containing zero XCFrameworks all fail the release here.
78+
REQUIRE_XCFRAMEWORK_SIGNATURE: '1'
79+
steps:
80+
- name: Checkout repository
81+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
82+
with:
83+
persist-credentials: false
84+
85+
- name: Download unsigned Darwin archives
86+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
87+
with:
88+
pattern: darwin-unsigned-*
89+
path: unsigned
90+
merge-multiple: true
91+
92+
- name: Import Apple Distribution certificate into a temporary keychain
93+
env:
94+
CERT_P12_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_BASE64 }}
95+
CERT_P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_PASSWORD }}
96+
run: |
97+
set -euo pipefail
98+
: "${CERT_P12_BASE64:?APPLE_DISTRIBUTION_CERT_P12_BASE64 is not set}"
99+
: "${CERT_P12_PASSWORD:?APPLE_DISTRIBUTION_CERT_P12_PASSWORD is not set}"
100+
101+
KEYCHAIN_PATH="$RUNNER_TEMP/xcframework-signing.keychain-db"
102+
CERT_PATH="$RUNNER_TEMP/xcframework-signing.p12"
103+
# Ephemeral: the keychain lives for this job only and is deleted in the
104+
# always-run cleanup step, so the password never leaves this step.
105+
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
106+
107+
printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
108+
109+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
110+
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
111+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
112+
113+
# No -A: the private key is reachable only by the two Apple tools named
114+
# below, not by any process that happens to run in this job.
115+
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \
116+
-f pkcs12 -T /usr/bin/codesign -T /usr/bin/security
117+
security set-key-partition-list -S apple-tool:,apple: -s \
118+
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
119+
120+
# codesign resolves an identity through the search list even when
121+
# --keychain is passed, so prepend ours to the user list.
122+
security list-keychains -d user -s "$KEYCHAIN_PATH" \
123+
$(security list-keychains -d user | tr -d '"')
124+
125+
# Derive EXACTLY ONE fingerprint. Selecting by display name is
126+
# ambiguous when a keychain holds more than one matching certificate,
127+
# and codesign then picks arbitrarily; a hard count check turns a
128+
# multi-certificate .p12 into a build failure instead of a coin flip.
129+
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH")
130+
echo "$IDENTITIES"
131+
FPRS=$(printf '%s\n' "$IDENTITIES" \
132+
| sed -n 's/^ *[0-9]*) \([0-9A-F]\{40\}\) .*/\1/p' | sort -u)
133+
COUNT=$(printf '%s' "$FPRS" | grep -c . || true)
134+
if [ "$COUNT" -ne 1 ]; then
135+
echo "::error::expected exactly 1 codesigning identity in the imported keychain, found $COUNT"
136+
exit 1
137+
fi
138+
139+
# Fingerprint and keychain path are not secrets.
140+
echo "XCFRAMEWORK_CODESIGN_IDENTITY=$FPRS" >> "$GITHUB_ENV"
141+
echo "XCFRAMEWORK_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
142+
143+
- name: Sign and re-pack Darwin archives
144+
shell: bash
145+
run: |
146+
set -euo pipefail
147+
shopt -s nullglob
148+
archives=(unsigned/python-*.tar.gz)
149+
if [ "${#archives[@]}" -eq 0 ]; then
150+
echo "::error::no Darwin archives to sign"
151+
exit 1
152+
fi
153+
bash darwin/sign_darwin_archives.sh signed "${archives[@]}"
154+
155+
- name: Upload signed Darwin archives
156+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
157+
with:
158+
name: python-darwin-signed
159+
path: signed/python-*.tar.gz
160+
if-no-files-found: error
161+
162+
- name: Remove temporary keychain and certificate
163+
if: always()
164+
run: |
165+
security delete-keychain "$RUNNER_TEMP/xcframework-signing.keychain-db" 2>/dev/null || true
166+
rm -f "$RUNNER_TEMP/xcframework-signing.p12"
167+
55168
publish-release:
56169
name: Publish Release Assets
57170
runs-on: ubuntu-latest
58171
# Date-keyed releases (PBS-style): only publish when an operator explicitly
59-
# triggers via workflow_dispatch with a `release_date` input. Pushes still
60-
# exercise the matrix but leave per-job artifacts for inspection and do
61-
# not touch GitHub releases.
62-
if: github.event_name == 'workflow_dispatch' && inputs.release_date != ''
172+
# triggers via workflow_dispatch with a `release_date` input, and only from
173+
# the protected main branch — the same condition that gates real signing, so
174+
# a release can never be assembled from artifacts that were never signed.
175+
if: >-
176+
github.event_name == 'workflow_dispatch'
177+
&& inputs.release_date != ''
178+
&& github.ref == 'refs/heads/main'
63179
needs:
64180
- setup
65181
- build-matrix
182+
- sign-darwin-artifacts
66183
permissions:
67184
contents: write
68185
steps:
@@ -71,13 +188,51 @@ jobs:
71188
with:
72189
persist-credentials: false
73190

74-
- name: Download all build artifacts
191+
# Downloaded by explicit pattern, never `python-*` wholesale: the unsigned
192+
# Darwin build artifacts are named `darwin-unsigned-*` precisely so no
193+
# pattern here can reach them. The only Darwin tarballs that enter the
194+
# release directory are the ones sign-darwin-artifacts produced.
195+
- name: Download Android build artifacts
196+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
197+
with:
198+
pattern: python-android-*
199+
path: release-artifacts
200+
merge-multiple: true
201+
202+
- name: Download Linux build artifacts
75203
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
76204
with:
77-
pattern: python-*
205+
pattern: python-linux-*
78206
path: release-artifacts
79207
merge-multiple: true
80208

209+
- name: Download Windows build artifacts
210+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
211+
with:
212+
pattern: python-windows-*
213+
path: release-artifacts
214+
merge-multiple: true
215+
216+
- name: Download signed Darwin artifacts
217+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
218+
with:
219+
name: python-darwin-signed
220+
path: release-artifacts
221+
222+
- name: Assert the release payload carries the signed Darwin tarballs
223+
shell: bash
224+
run: |
225+
set -euo pipefail
226+
shopt -s nullglob
227+
ls -lh release-artifacts
228+
for kind in python-ios-dart python-macos-dart python-ios-mobile-forge; do
229+
found=(release-artifacts/$kind-*.tar.gz)
230+
if [ "${#found[@]}" -eq 0 ]; then
231+
echo "::error::no $kind-*.tar.gz in the release payload"
232+
exit 1
233+
fi
234+
done
235+
81236
- name: Add runtime manifest (with release date) to the release
82237
# Publish the same manifest.json that drove this build, with the release
83238
# date injected, so consumers can fetch a consistent version set by date.

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,41 @@ Releases are date-keyed (`YYYYMMDD`) and cut manually:
6262
A push without a `release_date` still exercises the full matrix but publishes no
6363
release (per-job artifacts only).
6464

65+
### Apple XCFramework signing
66+
67+
Every `.xcframework` in the Darwin tarballs is provider-signed with the Flet
68+
publishing team's Apple Distribution identity and a secure timestamp.
69+
70+
This matters because Xcode records the state of each `.xcframework` an app links
71+
against **as its publisher shipped it**, and writes the result into the IPA as
72+
`Signatures/<name>.xcframework-ios.signature`. Unsigned artifacts make those
73+
receipts read `signed = false` / `isSecureTimestamp = false`, which Apple's App
74+
Store scan reports as `ITMS-91065: Missing signature`. The app's own signature is
75+
a separate thing and does not fill it in.
76+
77+
Signing runs in `sign-darwin-artifacts`, a release-only job gated on a
78+
`workflow_dispatch` with a `release_date` **from `main`**, using the protected
79+
`release-signing` environment. It downloads the finished Darwin archives, signs
80+
every xcframework inside them, re-packs, and re-verifies after extraction — so the
81+
certificate is never present in the build matrix that runs on pushes and PRs,
82+
while the "sign only after every mutation" ordering is still guaranteed.
83+
84+
The unsigned build artifacts are named `darwin-unsigned-*` rather than
85+
`python-darwin-*` specifically so that no `python-*` download pattern in the
86+
publish job can reach them. `publish-release` downloads Android/Linux/Windows
87+
artifacts by explicit pattern plus the `python-darwin-signed` bundle, and fails if
88+
the expected Darwin tarballs are absent.
89+
90+
Framework bundle identifiers are assigned here, before signing, and are
91+
publisher-owned and stable: `dev.flet.python.runtime` for the runtime and
92+
`dev.flet.python.<module>` for each stdlib extension. They must not depend on the
93+
consuming app — see `darwin/xcframework_identifiers.sh`, which validates that they
94+
are unique, syntactically valid, and consistent across device and simulator slices.
95+
96+
Local builds without signing credentials still work and produce unsigned artifacts;
97+
`REQUIRE_XCFRAMEWORK_SIGNATURE=1` (set on the release path) turns every missing
98+
credential, missing timestamp, wrong team, or empty archive into a hard failure.
99+
65100
## Consumers
66101

67102
- **serious_python** pins a release date, fetches that release's `manifest.json`,

darwin/package-ios-for-dart.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ rm -rf $frameworks_dir/Python.xcframework/lib
4444
cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64/Python.framework
4545
cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64_x86_64-simulator/Python.framework
4646

47+
# Stable, provider-owned identifier for the Python runtime, replacing CPython's
48+
# shared `org.python.python`. Assigned here — while the xcframework is still
49+
# unsigned — because the downstream signing job seals the bundle and any later
50+
# plist edit invalidates that seal. serious_python used to rewrite this to the
51+
# consuming app's bundle id; it no longer does, and must not.
52+
xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER"
53+
4754
# copy stdlibs
4855
for arch in "${archs[@]}"; do
4956
rsync -av --exclude-from=$script_dir/python-darwin-stdlib.exclude $python_apple_support_root/install/iOS/$arch/python-*/lib/python$python_version_short/* $stdlib_dir/$arch
@@ -66,6 +73,23 @@ find "$stdlib_dir/${archs[0]}/lib-dynload" -name "*.$dylib_ext" | while read ful
6673
#break # run for one lib only - for tests
6774
done
6875

76+
# The privacy manifests are copied into the frameworks by
77+
# create_xcframework_from_dylibs above. Apple's scan reads them out of the
78+
# embedded framework, so a silent miss here surfaces much later as an App Store
79+
# rejection — assert instead.
80+
for _pm in _ssl _hashlib; do
81+
for _slice in ios-arm64 ios-arm64_x86_64-simulator; do
82+
_pm_path="$python_frameworks_dir/$_pm.xcframework/$_slice/$_pm.framework/PrivacyInfo.xcprivacy"
83+
[ -f "$_pm_path" ] || { echo "missing privacy manifest: $_pm_path"; exit 1; }
84+
done
85+
done
86+
echo "Privacy manifests present for _ssl and _hashlib"
87+
88+
# Last plist-touching step before the archive: every identifier must be valid,
89+
# unique, consistent across slices, and provider-owned. Anything that fails here
90+
# would otherwise be signed as-is and only diagnosed from an IPA.
91+
xcf_validate_identifiers "$frameworks_dir" "$python_frameworks_dir"
92+
6993
mv $stdlib_dir/${archs[0]}/* $stdlib_dir
7094

7195
# cleanup

darwin/package-macos-for-dart.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ python_version=${2:?}
66

77
script_dir=$(dirname $(realpath $0))
88

9+
# shellcheck source=darwin/xcframework_identifiers.sh
10+
. "$script_dir/xcframework_identifiers.sh"
11+
912
# build short Python version
1013
read python_version_major python_version_minor < <(echo $python_version | sed -E 's/^([0-9]+)\.([0-9]+).*/\1 \2/')
1114
python_version_short=$python_version_major.$python_version_minor
@@ -38,6 +41,14 @@ cp -r $python_apple_support_root/support/$python_version_short/macOS/Python.xcfr
3841
# overlaid darwin/Modules/module.modulemap above; -f tolerates builds without one.
3942
rm -f $frameworks_dir/Python.xcframework/macos-arm64_x86_64/Python.framework/Headers/module.modulemap
4043

44+
# Last mutation of the bundle: stable, provider-owned identifier for the Python
45+
# runtime, replacing CPython's shared `org.python.python`. The macOS framework is
46+
# versioned, so this writes Versions/<short>/Resources/Info.plist and leaves the
47+
# nested Python.app's own identifier alone. Must precede signing — see
48+
# xcframework_identifiers.sh.
49+
xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER"
50+
xcf_validate_identifiers "$frameworks_dir"
51+
4152
# copy stdlibs
4253
rsync -av --exclude-from=$script_dir/python-darwin-stdlib.exclude $python_apple_support_root/install/macOS/macosx/python-*/Python.framework/Versions/Current/lib/python$python_version_short/* $stdlib_dir
4354

0 commit comments

Comments
 (0)