#!/bin/zsh # # Build the macadmins Python 3 framework. # Produces an installable .pkg (when signing identities are supplied) and a # portable framework zip targeting Apple Silicon. # # Adapted from https://github.com/munki/munki/blob/Munki3dev/code/tools/build_python_framework.sh set -eu # --- Pinned upstream commits --- RP_SHA="8ee72fe3a5dbef733365370ebf44f25022b895ef" # gregneagle/relocatable-python MP_SHA="bbd07730d1b93ed3828246575ef5676bba74b5d1" # munki/munki-pkg # --- Paths and constants --- TYPE="recommended" FRAMEWORKDIR="/Library/ManagedFrameworks/Python" PYTHON_BIN_NEW="$FRAMEWORKDIR/Python3.framework/Versions/Current/Resources/Python.app/Contents/MacOS/Python" PYTHON_BASEURL="https://www.python.org/ftp/python/%s/python-%s-macos%s.pkg" TOOLSDIR="$(/usr/bin/dirname "$0")" OUTPUTSDIR="$TOOLSDIR/outputs" RP_BINDIR="/tmp/relocatable-python" MP_BINDIR="/tmp/munki-pkg" RP_ZIP="/tmp/relocatable-python.zip" MP_ZIP="/tmp/munki-pkg.zip" CONSOLEUSER="$(/usr/bin/stat -f "%Su" /dev/console)" PIPCACHEDIR="/Users/${CONSOLEUSER}/Library/Caches/pip" # --- CLI arguments (set by parse_args) --- PYTHON_VERSION="" INSTALLER_ID="" APPLICATION_ID="" NOTARY_PASSWORD="" XCODE_PATH="" usage() { cat <&2; usage; exit 1 ;; esac done if [[ -z "$PYTHON_VERSION" ]]; then echo "error: --python-version is required" >&2 usage exit 1 fi PYTHON_MAJOR_VERSION="${PYTHON_VERSION%.*}" # 3.13.13 -> 3.13 PYTHON_BIN_VERSION="$PYTHON_MAJOR_VERSION" } is_ci() { [[ -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" ]] } derive_build_version() { local rev_count rev_count="$(/usr/bin/git -C "$TOOLSDIR" rev-list --count HEAD)" NEWSUBBUILD=$((80620 + rev_count)) AUTOMATED_PYTHON_BUILD="$PYTHON_VERSION.$NEWSUBBUILD" echo "$AUTOMATED_PYTHON_BUILD" > "$TOOLSDIR/build_info.txt" echo "Build version: $AUTOMATED_PYTHON_BUILD" } prepare_ci_env() { if ! is_ci; then return fi echo "CI detected — clearing Homebrew and selecting Xcode." /usr/local/bin/brew remove --force "$(/usr/local/bin/brew list)" || true if [[ -n "$XCODE_PATH" && -d "$XCODE_PATH" ]]; then /usr/bin/sudo /usr/bin/xcode-select -s "$XCODE_PATH" fi } prepare_build_dirs() { /usr/bin/sudo /bin/mkdir -p "$FRAMEWORKDIR" # mkdir -m only applies to newly created dirs; ensure existing dirs are writable. /usr/bin/sudo /bin/chmod 777 "$FRAMEWORKDIR" if [[ -d "$FRAMEWORKDIR/Python.framework" ]]; then /usr/bin/sudo /bin/rm -rf "$FRAMEWORKDIR/Python.framework" fi if [[ -d "$PIPCACHEDIR" ]]; then echo "Removing pip cache to reduce build errors" /usr/bin/sudo /bin/rm -rf "$PIPCACHEDIR" fi /bin/rm -rf "$TOOLSDIR/$TYPE" /bin/mkdir -p "$TOOLSDIR/$TYPE/scripts" /bin/mkdir -p "$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}" /bin/mkdir -p "$TOOLSDIR/$TYPE/payload/usr/local/bin" /usr/bin/sudo /usr/sbin/chown -R "${CONSOLEUSER}":wheel "$TOOLSDIR/$TYPE" /bin/ln -s "$PYTHON_BIN_NEW" "$TOOLSDIR/$TYPE/payload/usr/local/bin/managed_python3" } download_tool() { local name="$1" sha="$2" url="$3" zip_path="$4" dest="$5" echo "Downloading $name @ $sha" /bin/rm -rf "$zip_path" "$dest" /usr/bin/curl -fL "$url" -o "$zip_path" /usr/bin/unzip -q "$zip_path" -d "$dest" } build_framework() { export C_INCLUDE_PATH="/Library/ManagedFrameworks/Python/Python.framework/Versions/Current/Headers/" local rp_extract="${RP_BINDIR}/relocatable-python-${RP_SHA}" "${rp_extract}/make_relocatable_python_framework.py" \ --baseurl "${PYTHON_BASEURL}" \ --python-version "${PYTHON_VERSION}" \ --os-version 11 \ --upgrade-pip \ --pip-requirements "${TOOLSDIR}/requirements_${TYPE}.txt" \ --destination "${FRAMEWORKDIR}" /bin/mv "${FRAMEWORKDIR}/Python.framework" \ "$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework" } install_sitecustomize() { # Drop a sitecustomize.py that points OpenSSL at certifi's CA bundle via # SSL_CERT_FILE at interpreter startup. Works around macadmins/python#38: # python.org's framework has the OpenSSL CA path compiled in to # /Library/Frameworks/... which doesn't match our relocated install. local site_packages="$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework/Versions/${PYTHON_BIN_VERSION}/lib/python${PYTHON_BIN_VERSION}/site-packages" /bin/cp "${TOOLSDIR}/managed_python_sitecustomize.py" "$site_packages/sitecustomize.py" } codesign_framework() { local identity="${APPLICATION_ID:--}" # `-` means ad-hoc local framework_root="$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework" local versioned="$framework_root/Versions/${PYTHON_BIN_VERSION}" local nested_frameworks_dir="$versioned/Frameworks" if [[ "$identity" == "-" ]]; then echo "Ad-hoc signing framework" else echo "Signing framework with identity: $identity" fi # Codesign notes: # - Use --options=runtime to force-enable hardened runtime (required for # notarization on macOS 13+). Do NOT include `runtime` in # --preserve-metadata: install_name_tool just invalidated the prior # signature, so there is nothing reliable to inherit. # - Do NOT sign Versions/Current/Python — it's a symlink to # Versions/X.Y/Python which we already signed. Double-signing through # the symlink corrupts the signature on newer Python frameworks. # - Sign nested .framework bundles (Tcl, Tk in Python 3.13+) with --deep, # not via per-binary find. The bundle's _CodeSignature/CodeResources # file must be regenerated to match the re-signed inner binary; # otherwise notarytool reports "nested code is modified or invalid". local -a cs_args if [[ "$identity" == "-" ]]; then cs_args=(--options=runtime --preserve-metadata=identifier,entitlements,flags -f) else cs_args=(--timestamp --options=runtime --preserve-metadata=identifier,entitlements,flags -f) fi /usr/bin/find "$versioned/bin" -type f -perm -u=x -exec \ /usr/bin/codesign -s "$identity" "${cs_args[@]}" {} \; /usr/bin/find "$versioned/lib" -type f -perm -u=x -exec \ /usr/bin/codesign -s "$identity" "${cs_args[@]}" {} \; /usr/bin/find "$versioned/lib" -type f -name "*dylib" -exec \ /usr/bin/codesign -s "$identity" "${cs_args[@]}" {} \; if [[ -d "$nested_frameworks_dir" ]]; then local nested_fw for nested_fw in "$nested_frameworks_dir"/*.framework; do [[ -d "$nested_fw" ]] || continue if [[ "$identity" == "-" ]]; then /usr/bin/codesign -s "$identity" --options=runtime --force --deep "$nested_fw" else /usr/bin/codesign -s "$identity" --timestamp --options=runtime --force --deep "$nested_fw" fi done fi /usr/bin/codesign -s "$identity" --deep "${cs_args[@]}" "$versioned/Resources/Python.app" /usr/bin/codesign -s "$identity" "${cs_args[@]}" "$versioned/Python" /usr/sbin/spctl -a -vvvv "$versioned/Python" || true } build_pkg() { /bin/mkdir -p "$OUTPUTSDIR" /bin/cp "${TOOLSDIR}/preinstall-cleanup" "$TOOLSDIR/$TYPE/scripts/preinstall" if [[ -z "$INSTALLER_ID" ]]; then echo "No installer identity provided; skipping signed pkg" return fi /bin/cat < "$TOOLSDIR/$TYPE/build-info.json" { "ownership": "recommended", "suppress_bundle_relocation": true, "identifier": "io.macadmins.python.$TYPE", "postinstall_action": "none", "distribution_style": true, "version": "$AUTOMATED_PYTHON_BUILD", "name": "python_${TYPE}_signed-$AUTOMATED_PYTHON_BUILD.pkg", "install_location": "/", "preserve_xattr": true, "signing_info": { "identity": "$INSTALLER_ID", "timestamp": true } } JSON "${MP_BINDIR}/munki-pkg-${MP_SHA}/munkipkg" "$TOOLSDIR/$TYPE" local pkg_built="$TOOLSDIR/$TYPE/build/python_${TYPE}_signed-$AUTOMATED_PYTHON_BUILD.pkg" /bin/mv "$pkg_built" "$OUTPUTSDIR" } notarize_and_staple() { if [[ -z "$NOTARY_PASSWORD" || -z "$INSTALLER_ID" ]]; then echo "Skipping notarization (no notary password or installer id)" return fi local xcode_dev xcode_notary xcode_stapler pkg xcode_dev="$(/usr/bin/xcode-select -p)" xcode_notary="$xcode_dev/usr/bin/notarytool" xcode_stapler="$xcode_dev/usr/bin/stapler" pkg="$OUTPUTSDIR/python_${TYPE}_signed-$AUTOMATED_PYTHON_BUILD.pkg" "$xcode_notary" store-credentials \ --apple-id "opensource@macadmins.io" \ --team-id "T4SK8ZXCXG" \ --password "$NOTARY_PASSWORD" \ macadminpython "$xcode_notary" submit "$pkg" --keychain-profile macadminpython --wait "$xcode_stapler" staple "$pkg" } zip_framework() { local zipfile="Python3.framework_$TYPE-$AUTOMATED_PYTHON_BUILD.zip" /usr/bin/ditto -c -k --sequesterRsrc \ "$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/" "$zipfile" /bin/mv "$zipfile" "$OUTPUTSDIR" /usr/bin/sudo /usr/sbin/chown -R "${CONSOLEUSER}":wheel "$OUTPUTSDIR" } cleanup() { /usr/bin/sudo /bin/rm -rf "$TOOLSDIR/$TYPE" /usr/bin/sudo /bin/rm -rf "$FRAMEWORKDIR" } # --- Main --- parse_args "$@" echo "Building Python framework — $PYTHON_VERSION" prepare_ci_env derive_build_version prepare_build_dirs download_tool relocatable-python "$RP_SHA" \ "https://github.com/gregneagle/relocatable-python/archive/${RP_SHA}.zip" \ "$RP_ZIP" "$RP_BINDIR" download_tool munki-pkg "$MP_SHA" \ "https://github.com/munki/munki-pkg/archive/${MP_SHA}.zip" \ "$MP_ZIP" "$MP_BINDIR" build_framework install_sitecustomize codesign_framework build_pkg notarize_and_staple zip_framework cleanup echo "Done."