Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/build-python-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ jobs:
# iOS/README.rst, 3.12 uses beeware. Emits normalized ./install + ./support.
python build_ios.py "$PYTHON_VERSION"

# build_ios.py mutates the CPython-created Python.xcframework after assembly
# (sysconfigdata rewrites, `strip -x` of every slice binary), so this is the
# first moment the bundle is final. Give it its stable provider identifier
# here — the release signing job seals it, and a plist edit afterwards would
# invalidate that seal.
bash ./xcframework_identifiers.sh set \
"support/$PYTHON_VERSION_SHORT/iOS/Python.xcframework" dev.flet.python.runtime

# mobile-forge artifact: iOS-only install+support tree (same structure as before).
# Captured before the macOS build also writes into ./support / ./install.
tar -czf dist/python-ios-mobile-forge-$PYTHON_VERSION.tar.gz install support
Expand All @@ -73,10 +81,15 @@ jobs:
bash ./package-ios-for-dart.sh . "$PYTHON_VERSION"
bash ./package-macos-for-dart.sh . "$PYTHON_VERSION"

- name: Upload Darwin build artifacts
# Deliberately NOT named `python-darwin-*`: the XCFrameworks in these
# tarballs are unsigned, and the publish job selects release assets with
# `python-*` patterns. Keeping the unsigned Darwin build outside that
# namespace makes it structurally impossible for it to be published by
# accident — sign-darwin-artifacts re-emits them as `python-darwin-signed`.
- name: Upload Darwin build artifacts (unsigned)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-darwin-${{ env.PYTHON_VERSION }}
name: darwin-unsigned-${{ env.PYTHON_VERSION }}
path: darwin/dist/python-*.tar.gz
if-no-files-found: error

Expand Down
167 changes: 161 additions & 6 deletions .github/workflows/build-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,134 @@
with:
python_version: ${{ matrix.python_version }}

# Release-only, isolated signing job. The provider certificate is never
# available to build-matrix (which runs on every push and PR); it lives in the
# protected `release-signing` environment and is only reachable from an
# explicit release dispatch on main.
#
# Signing operates on the finished archives rather than inside the build, which
# guarantees the required ordering: every mutation (install names, plists,
# privacy manifests, headers, pruning, stripping) is already done by the time
# an archive exists. The script re-packs and re-verifies after a round trip.
sign-darwin-artifacts:
name: Provider-sign Darwin XCFrameworks
runs-on: macos-26
if: >-
github.event_name == 'workflow_dispatch'
&& inputs.release_date != ''
&& github.ref == 'refs/heads/main'

Check warning

Code scanning / zizmor

action's hash pin has mismatched or missing version comment: points to commit d23441a48e51 Warning

action's hash pin has mismatched or missing version comment: points to commit d23441a48e51
needs:
- build-matrix
environment: release-signing
env:
XCFRAMEWORK_EXPECTED_TEAM_ID: ${{ vars.XCFRAMEWORK_EXPECTED_TEAM_ID }}
# Missing credentials, a missing secure timestamp, a wrong team, or an
# archive containing zero XCFrameworks all fail the release here.
REQUIRE_XCFRAMEWORK_SIGNATURE: '1'
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false

- name: Download unsigned Darwin archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: darwin-unsigned-*
path: unsigned
merge-multiple: true

- name: Import Apple Distribution certificate into a temporary keychain
env:
CERT_P12_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_BASE64 }}
CERT_P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_PASSWORD }}
run: |
set -euo pipefail
: "${CERT_P12_BASE64:?APPLE_DISTRIBUTION_CERT_P12_BASE64 is not set}"
: "${CERT_P12_PASSWORD:?APPLE_DISTRIBUTION_CERT_P12_PASSWORD is not set}"

KEYCHAIN_PATH="$RUNNER_TEMP/xcframework-signing.keychain-db"
CERT_PATH="$RUNNER_TEMP/xcframework-signing.p12"
# Ephemeral: the keychain lives for this job only and is deleted in the
# always-run cleanup step, so the password never leaves this step.
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)

printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"

security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

# No -A: the private key is reachable only by the two Apple tools named
# below, not by any process that happens to run in this job.
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \
-f pkcs12 -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null

# codesign resolves an identity through the search list even when
# --keychain is passed, so prepend ours to the user list.
security list-keychains -d user -s "$KEYCHAIN_PATH" \
$(security list-keychains -d user | tr -d '"')

# Derive EXACTLY ONE fingerprint. Selecting by display name is
# ambiguous when a keychain holds more than one matching certificate,
# and codesign then picks arbitrarily; a hard count check turns a
# multi-certificate .p12 into a build failure instead of a coin flip.
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH")
echo "$IDENTITIES"
FPRS=$(printf '%s\n' "$IDENTITIES" \
| sed -n 's/^ *[0-9]*) \([0-9A-F]\{40\}\) .*/\1/p' | sort -u)
COUNT=$(printf '%s' "$FPRS" | grep -c . || true)
if [ "$COUNT" -ne 1 ]; then
echo "::error::expected exactly 1 codesigning identity in the imported keychain, found $COUNT"
exit 1
fi

# Fingerprint and keychain path are not secrets.
echo "XCFRAMEWORK_CODESIGN_IDENTITY=$FPRS" >> "$GITHUB_ENV"
echo "XCFRAMEWORK_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"

- name: Sign and re-pack Darwin archives
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(unsigned/python-*.tar.gz)
if [ "${#archives[@]}" -eq 0 ]; then
echo "::error::no Darwin archives to sign"
exit 1
fi
bash darwin/sign_darwin_archives.sh signed "${archives[@]}"

- name: Upload signed Darwin archives
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-darwin-signed
path: signed/python-*.tar.gz
if-no-files-found: error

- name: Remove temporary keychain and certificate
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/xcframework-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/xcframework-signing.p12"

publish-release:
name: Publish Release Assets
runs-on: ubuntu-latest
# Date-keyed releases (PBS-style): only publish when an operator explicitly
# triggers via workflow_dispatch with a `release_date` input. Pushes still
# exercise the matrix but leave per-job artifacts for inspection and do
# not touch GitHub releases.
if: github.event_name == 'workflow_dispatch' && inputs.release_date != ''
# triggers via workflow_dispatch with a `release_date` input, and only from
# the protected main branch — the same condition that gates real signing, so
# a release can never be assembled from artifacts that were never signed.
if: >-
github.event_name == 'workflow_dispatch'
&& inputs.release_date != ''
&& github.ref == 'refs/heads/main'
needs:
- setup
- build-matrix
- sign-darwin-artifacts
permissions:
contents: write
steps:
Expand All @@ -71,13 +188,51 @@
with:
persist-credentials: false

- name: Download all build artifacts
# Downloaded by explicit pattern, never `python-*` wholesale: the unsigned
# Darwin build artifacts are named `darwin-unsigned-*` precisely so no
# pattern here can reach them. The only Darwin tarballs that enter the
# release directory are the ones sign-darwin-artifacts produced.
- name: Download Android build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-android-*
path: release-artifacts
merge-multiple: true

- name: Download Linux build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-*
pattern: python-linux-*
path: release-artifacts
merge-multiple: true

- name: Download Windows build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-windows-*
path: release-artifacts
merge-multiple: true

- name: Download signed Darwin artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: python-darwin-signed
path: release-artifacts

- name: Assert the release payload carries the signed Darwin tarballs
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
ls -lh release-artifacts
for kind in python-ios-dart python-macos-dart python-ios-mobile-forge; do
found=(release-artifacts/$kind-*.tar.gz)
if [ "${#found[@]}" -eq 0 ]; then
echo "::error::no $kind-*.tar.gz in the release payload"
exit 1
fi
done

- name: Add runtime manifest (with release date) to the release
# Publish the same manifest.json that drove this build, with the release
# date injected, so consumers can fetch a consistent version set by date.
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,41 @@ Releases are date-keyed (`YYYYMMDD`) and cut manually:
A push without a `release_date` still exercises the full matrix but publishes no
release (per-job artifacts only).

### Apple XCFramework signing

Every `.xcframework` in the Darwin tarballs is provider-signed with the Flet
publishing team's Apple Distribution identity and a secure timestamp.

This matters because Xcode records the state of each `.xcframework` an app links
against **as its publisher shipped it**, and writes the result into the IPA as
`Signatures/<name>.xcframework-ios.signature`. Unsigned artifacts make those
receipts read `signed = false` / `isSecureTimestamp = false`, which Apple's App
Store scan reports as `ITMS-91065: Missing signature`. The app's own signature is
a separate thing and does not fill it in.

Signing runs in `sign-darwin-artifacts`, a release-only job gated on a
`workflow_dispatch` with a `release_date` **from `main`**, using the protected
`release-signing` environment. It downloads the finished Darwin archives, signs
every xcframework inside them, re-packs, and re-verifies after extraction — so the
certificate is never present in the build matrix that runs on pushes and PRs,
while the "sign only after every mutation" ordering is still guaranteed.

The unsigned build artifacts are named `darwin-unsigned-*` rather than
`python-darwin-*` specifically so that no `python-*` download pattern in the
publish job can reach them. `publish-release` downloads Android/Linux/Windows
artifacts by explicit pattern plus the `python-darwin-signed` bundle, and fails if
the expected Darwin tarballs are absent.

Framework bundle identifiers are assigned here, before signing, and are
publisher-owned and stable: `dev.flet.python.runtime` for the runtime and
`dev.flet.python.<module>` for each stdlib extension. They must not depend on the
consuming app — see `darwin/xcframework_identifiers.sh`, which validates that they
are unique, syntactically valid, and consistent across device and simulator slices.

Local builds without signing 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.

## Consumers

- **serious_python** pins a release date, fetches that release's `manifest.json`,
Expand Down
24 changes: 24 additions & 0 deletions darwin/package-ios-for-dart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ rm -rf $frameworks_dir/Python.xcframework/lib
cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64/Python.framework
cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64_x86_64-simulator/Python.framework

# Stable, provider-owned identifier for the Python runtime, replacing CPython's
# shared `org.python.python`. Assigned here — while the xcframework is still
# unsigned — because the downstream signing job seals the bundle and any later
# plist edit invalidates that seal. serious_python used to rewrite this to the
# consuming app's bundle id; it no longer does, and must not.
xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER"

# copy stdlibs
for arch in "${archs[@]}"; do
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
Expand All @@ -66,6 +73,23 @@ find "$stdlib_dir/${archs[0]}/lib-dynload" -name "*.$dylib_ext" | while read ful
#break # run for one lib only - for tests
done

# The privacy manifests are copied into the frameworks by
# create_xcframework_from_dylibs above. Apple's scan reads them out of the
# embedded framework, so a silent miss here surfaces much later as an App Store
# rejection — assert instead.
for _pm in _ssl _hashlib; do
for _slice in ios-arm64 ios-arm64_x86_64-simulator; do
_pm_path="$python_frameworks_dir/$_pm.xcframework/$_slice/$_pm.framework/PrivacyInfo.xcprivacy"
[ -f "$_pm_path" ] || { echo "missing privacy manifest: $_pm_path"; exit 1; }
done
done
echo "Privacy manifests present for _ssl and _hashlib"

# Last plist-touching step before the archive: every identifier must be valid,
# unique, consistent across slices, and provider-owned. Anything that fails here
# would otherwise be signed as-is and only diagnosed from an IPA.
xcf_validate_identifiers "$frameworks_dir" "$python_frameworks_dir"

mv $stdlib_dir/${archs[0]}/* $stdlib_dir

# cleanup
Expand Down
11 changes: 11 additions & 0 deletions darwin/package-macos-for-dart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ python_version=${2:?}

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

# shellcheck source=darwin/xcframework_identifiers.sh
. "$script_dir/xcframework_identifiers.sh"

# build short Python version
read python_version_major python_version_minor < <(echo $python_version | sed -E 's/^([0-9]+)\.([0-9]+).*/\1 \2/')
python_version_short=$python_version_major.$python_version_minor
Expand Down Expand Up @@ -38,6 +41,14 @@ cp -r $python_apple_support_root/support/$python_version_short/macOS/Python.xcfr
# overlaid darwin/Modules/module.modulemap above; -f tolerates builds without one.
rm -f $frameworks_dir/Python.xcframework/macos-arm64_x86_64/Python.framework/Headers/module.modulemap

# Last mutation of the bundle: stable, provider-owned identifier for the Python
# runtime, replacing CPython's shared `org.python.python`. The macOS framework is
# versioned, so this writes Versions/<short>/Resources/Info.plist and leaves the
# nested Python.app's own identifier alone. Must precede signing — see
# xcframework_identifiers.sh.
xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER"
xcf_validate_identifiers "$frameworks_dir"

# copy stdlibs
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

Expand Down
Loading
Loading