Five things we learned during local validation that the original docs didn't anticipate: - PYTHON_BASEURL needs three %s slots (relocatable-python passes version, version, os-version) - mkdir -m 777 doesn't apply mode to existing dirs; need explicit chmod - --no-unsign disabled relocatable-python's own re-sign step and caused Apple Silicon Gatekeeper SIGKILLs at ensurepip time - Signed pkg must move to outputs/ before cleanup() runs - pyobjc 12.1 requires Python >= 3.10; 3.9 needs holdback to 11.1 Also confirmed all upstream SHAs (relocatable-python, munki-pkg) and all 37 Python package pins are at latest available as of 2026-05-11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
33 KiB
Apple Silicon Modernization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor build_python_framework_pkgs.zsh to run natively on Apple Silicon without universal2 enforcement, trim to a single build flavor, bump upstream SHAs and Python interpreter versions, add Python 3.14, and cut a final release for the EOL 3.9 and 3.10 branches.
Architecture: The build script is reorganized into named functions (parse_args, prepare_build_dirs, download_tool, build_framework, codesign_framework, build_pkg, notarize_and_staple, zip_framework, cleanup) with set -eu short-circuiting on failure. Long-flag arguments replace positional ones, with the major Python version derived from the full version. CI-only steps (Homebrew nuke, xcode-select) are gated behind $CI / $GITHUB_ACTIONS. The universal2 dylib/so audit is deleted; arm64 wheels from PyPI are used directly.
Tech Stack: zsh, relocatable-python, munki-pkg, codesign, notarytool, GitHub Actions, Python 3.9 – 3.14.
File Structure
Modified files:
build_python_framework_pkgs.zsh— full rewrite into functions; drops universal check, ad-hoc/signed codesign duplication, per-version symlink branches, and CI-environment hardcodes. BumpsRP_SHAandMP_SHA.requirements_recommended.txt— drop--no-binarydirectives forblack,cffi,charset-normalizer,PyYAML,tomli,xattr. Bump package pins where newer versions have arm64 wheels across all supported branches.README.md— drop "Flavors of Python", "Minimal", "No Customization", and "build on Intel macOS" sections; update examples for Apple Silicon..github/workflows/build_python_3.11.yml— bumpPYTHON_VERSIONto3.11.9..github/workflows/build_python_3.12.yml— bumpPYTHON_VERSIONto3.12.10..github/workflows/build_python_3.13.yml— bumpPYTHON_VERSIONto3.13.13..github/workflows/build_python_3.9.yml— augment release notes to mark final release; leave Python version at3.9.13..github/workflows/build_python_3.10.yml— augment release notes to mark final release; leave Python version at3.10.11.
Created files:
.github/workflows/build_python_3.14.yml— new workflow for Python 3.14.5 (cloned and adapted from 3.13)..github/dependabot.yml— Dependabot config for GitHub Actions only (no pip).
Deleted files:
requirements_minimal.txtrequirements_no_customization.txtrequirement_files/requirements_minimal.txtrequirement_files/requirements_opinionated.txtbuild_all_python_frameworks.zsh
Task 1: Rewrite build_python_framework_pkgs.zsh
Files:
-
Modify:
build_python_framework_pkgs.zsh(full rewrite) -
Step 1: Replace the entire file contents
Replace the file with this content verbatim:
#!/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-macos11.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 <<EOF
Usage: $(/usr/bin/basename "$0") --python-version X.Y.Z [options]
Required:
--python-version Full Python version, e.g. 3.13.13
Optional (omit for an unsigned local build):
--installer-id Developer ID Installer identity
--application-id Developer ID Application identity
--notary-password App-specific password for notarytool
--xcode-path Path to Xcode.app (CI only)
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--python-version) PYTHON_VERSION="$2"; shift 2 ;;
--installer-id) INSTALLER_ID="$2"; shift 2 ;;
--application-id) APPLICATION_ID="$2"; shift 2 ;;
--notary-password) NOTARY_PASSWORD="$2"; shift 2 ;;
--xcode-path) XCODE_PATH="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&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 -m 777 -p "$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 \
--no-unsign \
--pip-requirements "${TOOLSDIR}/requirements_${TYPE}.txt" \
--destination "${FRAMEWORKDIR}"
/bin/mv "${FRAMEWORKDIR}/Python.framework" \
"$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework"
}
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}"
if [[ "$identity" == "-" ]]; then
echo "Ad-hoc signing framework"
else
echo "Signing framework with identity: $identity"
fi
local -a cs_args
if [[ "$identity" == "-" ]]; then
cs_args=(--preserve-metadata=identifier,entitlements,flags,runtime -f)
else
cs_args=(--timestamp --preserve-metadata=identifier,entitlements,flags,runtime -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[@]}" {} \;
/usr/bin/codesign -s "$identity" --deep "${cs_args[@]}" "$versioned/Resources/Python.app"
/usr/bin/codesign -s "$identity" "${cs_args[@]}" "$versioned/Python"
/usr/bin/codesign -s "$identity" "${cs_args[@]}" "$framework_root/Versions/Current/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 <<JSON > "$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"
}
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="$TOOLSDIR/$TYPE/build/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"
/bin/mv "$pkg" "$OUTPUTSDIR"
}
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
codesign_framework
build_pkg
notarize_and_staple
zip_framework
cleanup
echo "Done."
- Step 2: Lint with shellcheck (best-effort)
Run: shellcheck -s bash build_python_framework_pkgs.zsh || true
Expected: any issues are warnings about zsh-only constructs (e.g., [[ ]]); resolve real bugs only. shellcheck has limited zsh support — informational only.
- Step 3: Verify executable bit
Run: ls -l build_python_framework_pkgs.zsh
Expected: shows -rwxr-xr-x (mode 755). If not, run chmod +x build_python_framework_pkgs.zsh.
- Step 4: Commit
git add build_python_framework_pkgs.zsh
git commit -m "Refactor build script for Apple Silicon
- Drop universal2 enforcement; arm64 wheels used directly
- Single 'recommended' flavor; remove minimal/no_customization branches
- Long-flag arguments; derive major version from full version
- Functions: parse_args, prepare_build_dirs, download_tool,
build_framework, codesign_framework, build_pkg,
notarize_and_staple, zip_framework, cleanup
- Collapse signed/ad-hoc codesign duplication; fixes latent path bug
- Bump relocatable-python and munki-pkg SHAs
- Gate CI-only steps (brew remove, xcode-select) on \$CI"
Task 2: Drop --no-binary markers from requirements_recommended.txt
Files:
-
Modify:
requirements_recommended.txt -
Step 1: Remove all
--no-binarylines
Replace the file with:
asn1crypto==1.5.1
aspy.yaml==1.3.0
attrs==25.3.0
black==25.1.0
certifi==2025.6.15
cffi==1.17.1
cfgv==3.4.0
charset-normalizer==3.4.2
click==8.2.1
distlib==0.3.9
docklib==2.0.0
entrypoints==0.4
filelock==3.18.0
flake8==7.3.0
flake8-bugbear==24.12.12
identify==2.6.12
idna==3.10
isort==6.0.1
mccabe==0.7.0
mypy-extensions==1.1.0
nodeenv==1.9.1
packaging==25.0
pathspec==0.12.1
platformdirs==4.3.8
pre-commit==4.2.0
pycodestyle==2.14.0
pycparser==2.22
pyflakes==3.4.0
pyobjc==11.1
PyYAML==6.0.2
requests==2.32.4
six==1.17.0
tokenize-rt==6.2.0
tomli==2.2.1
urllib3==2.5.0
virtualenv==20.31.2
xattr==1.1.4
- Step 2: Verify no
--no-binaryremains
Run: grep -n -- '--no-binary' requirements_recommended.txt
Expected: no output (exit code 1 — no matches).
- Step 3: Commit
git add requirements_recommended.txt
git commit -m "Drop --no-binary directives (use prebuilt arm64 wheels)"
Task 3: Delete obsolete build flavors
Files:
-
Delete:
requirements_minimal.txt -
Delete:
requirements_no_customization.txt -
Delete:
requirement_files/requirements_minimal.txt -
Delete:
requirement_files/requirements_opinionated.txt -
Delete:
build_all_python_frameworks.zsh -
Step 1: Delete files
Run:
git rm requirements_minimal.txt requirements_no_customization.txt \
requirement_files/requirements_minimal.txt \
requirement_files/requirements_opinionated.txt \
build_all_python_frameworks.zsh
Expected: 5 files removed (rm 'requirements_minimal.txt', etc.).
- Step 2: Verify no remaining references
Run: grep -rn 'minimal\|no_customization\|build_all_python_frameworks' --include='*.zsh' --include='*.yml' --include='*.md' .
Expected: only matches in the design spec (docs/superpowers/specs/…) and the deletion-context release notes. If any active script or workflow still references them, fix that reference now.
- Step 3: Commit
git commit -m "Remove minimal and no_customization build flavors"
Task 4: Update README.md
Files:
-
Modify:
README.md -
Step 1: Replace the file contents
Replace README.md with:
# python
A Python 3 framework that installs to `/Library/ManagedFrameworks/Python/Python3.framework`.
Please see Apple's documentation on [file system basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) for context.
This is an intended replacement for `/usr/bin/python`, which Apple removed in macOS 12.3 (Spring 2022).
## Apple Silicon Only
Builds and packages target Apple Silicon (arm64). Universal2 outputs are no longer produced. Build hosts and target machines must be Apple Silicon Macs.
## Why use this instead of a package from python.org?
- Ships with PyObjC and other modules useful for Mac admins, similar in spirit to the Apple Python it replaces
- Installs to a location less likely to be overwritten, removed, or modified by other Python installations
## Using interactively
After installing the package, `/usr/local/bin/managed_python3` is a symlink to `/Library/ManagedFrameworks/Python/Python3.framework/Versions/Current/Resources/Python.app/Contents/MacOS/Python`.
## Using with scripts
Point your shebang directly at the symlink:
#!/Library/ManagedFrameworks/Python/Python3.framework/Versions/Current/bin/python3
print('This is an example script.')
### zshenv global alias
For zsh scripts you can add a global alias to `/etc/zshenv`:
`alias -g python3.framework='/Library/ManagedFrameworks/Python/Python3.framework/Versions/Current/bin/python3'`
See Armin Briegel's "Moving to Zsh" Part [II](https://scriptingosx.com/2019/06/moving-to-zsh-part-2-configuration-files/) and [IV](https://scriptingosx.com/2019/07/moving-to-zsh-part-4-aliases-and-functions/).
## Notes
Only a single package may be installed at any given time. The preinstall script removes any previous framework.
### Upgrades
Python itself has its own release cadence; this package will see additional updates as 3rd-party libraries release fixes and security updates. Always test your scripts before deploying broadly.
### Downgrades
Not supported.
### pip
`pip` is bundled but **not recommended** for installing external libraries into the framework. Use a [virtual environment](https://docs.python.org/3/library/venv.html) or a tool like [pyenv](https://github.com/pyenv/pyenv) instead. Pull requests to the `recommended` requirements file are welcome.
# Building locally
Build an unsigned framework on Apple Silicon with:
./build_python_framework_pkgs.zsh --python-version 3.13.13
Pass `--installer-id`, `--application-id`, and `--notary-password` to produce a signed and notarized `.pkg`.
# Updating packages
Do this in a clean virtual environment. After every Python package install, run `pip freeze | xargs pip uninstall -y` to reset the environment.
# CI Job
To update the signing certificate, run `base64 -i /path/to/certificate.p12 -o base64string` and import it into the GitHub Actions secrets store along with the matching password.
# Credits
Built on two open-source tools by [Greg Neagle](https://www.linkedin.com/in/gregneagle/):
- [relocatable-python](https://github.com/gregneagle/relocatable-python)
- [munki-pkg](https://github.com/munki/munki-pkg)
- Step 2: Verify no flavor references remain
Run: grep -E 'Minimal|No Customization|Flavors of Python|Intel macOS device' README.md
Expected: no output.
- Step 3: Commit
git add README.md
git commit -m "Update README for Apple Silicon, single-flavor build"
Task 5: Local validation — unsigned 3.13.13 build
Files: none (validation only)
- Step 1: Run unsigned build
Run: ./build_python_framework_pkgs.zsh --python-version 3.13.13
Expected:
-
Final lines include
Build version: 3.13.13.<N>andDone. -
outputs/Python3.framework_recommended-3.13.13.<N>.zipexists. -
No
outputs/*.pkg(no installer identity passed). -
No error about
2 architectures(the validation block is gone). -
Step 2: Install the framework and smoke-test
Run:
sudo rm -rf /Library/ManagedFrameworks/Python/Python3.framework
sudo mkdir -p /Library/ManagedFrameworks/Python
sudo ditto -x -k outputs/Python3.framework_recommended-3.13.13.*.zip /Library/ManagedFrameworks/Python/
sudo ln -sf /Library/ManagedFrameworks/Python/Python3.framework/Versions/Current/Resources/Python.app/Contents/MacOS/Python /usr/local/bin/managed_python3
managed_python3 --version
managed_python3 -c "import platform; print(platform.machine())"
managed_python3 -c "import objc, xattr, requests, yaml; print('ok')"
Expected output:
Python 3.13.13arm64ok
If import objc fails with Symbol not found or an architecture mismatch, halt and investigate — the framework is not arm64 compatible.
- Step 3: Tear down the test install
Run: sudo rm -rf /Library/ManagedFrameworks/Python/Python3.framework /usr/local/bin/managed_python3
Expected: no output.
- Step 4: Record the validation in the spec / plan
No commit yet — validation is a checkpoint. If steps 1 and 2 passed, mark this task done and move on.
Task 6: Local validation — unsigned 3.14.5 build
Files: none (validation only)
- Step 1: Run unsigned build
Run: ./build_python_framework_pkgs.zsh --python-version 3.14.5
Expected: Done. and outputs/Python3.framework_recommended-3.14.5.<N>.zip exists.
- Step 2: Smoke-test
Run the same install + smoke-test commands from Task 5 Step 2, substituting 3.14.5 for 3.13.13 in the zip filename.
Expected:
Python 3.14.5arm64ok
If any pip package fails to install during step 1 (no arm64 wheel for 3.14), record which package failed and proceed to Task 10 (pin sweep) to address.
- Step 3: Tear down the test install
Run: sudo rm -rf /Library/ManagedFrameworks/Python/Python3.framework /usr/local/bin/managed_python3
Task 7: Bump Python patch versions in 3.11 / 3.12 / 3.13 workflows
Files:
-
Modify:
.github/workflows/build_python_3.11.yml -
Modify:
.github/workflows/build_python_3.12.yml -
Modify:
.github/workflows/build_python_3.13.yml -
Step 1: Bump 3.11
In .github/workflows/build_python_3.11.yml, change PYTHON_VERSION: "3.11.7" to PYTHON_VERSION: "3.11.9". Also update the release-notes line - Upgraded Python to 3.11.7 to - Upgraded Python to 3.11.9.
- Step 2: Bump 3.12
In .github/workflows/build_python_3.12.yml, change PYTHON_VERSION: "3.12.1" to PYTHON_VERSION: "3.12.10". Also update the release-notes line - Upgraded Python to 3.12.1 to - Upgraded Python to 3.12.10.
- Step 3: Bump 3.13
In .github/workflows/build_python_3.13.yml, change PYTHON_VERSION: "3.13.5" to PYTHON_VERSION: "3.13.13". Also update the release-notes line - Upgraded Python to 3.13.5 to - Upgraded Python to 3.13.13.
- Step 4: Verify the script argument call still works
Search each workflow for the Run build package script step. Today it reads:
run: ./build_python_framework_pkgs.zsh "$TYPE" "$DEV_INSTALLER_ID" "$DEV_APPLICATION_ID" "$PYTHON_VERSION" "$PYTHON_MAJOR_VERSION" "${NOTARY_APP_PASSWORD}"
After Task 1 the script no longer accepts positional arguments, so this will break in CI. Phase 3 will rewrite the workflows, but to keep CI green for the in-between window, update the call in each of the three modified workflows to:
run: |
./build_python_framework_pkgs.zsh \
--python-version "$PYTHON_VERSION" \
--installer-id "$DEV_INSTALLER_ID" \
--application-id "$DEV_APPLICATION_ID" \
--notary-password "$NOTARY_APP_PASSWORD" \
--xcode-path "/Applications/Xcode_15.2.app"
The TYPE env var is unused by the new script; leave it in the env block for now (Phase 3 cleanup will remove it).
- Step 5: Commit
git add .github/workflows/build_python_3.11.yml \
.github/workflows/build_python_3.12.yml \
.github/workflows/build_python_3.13.yml
git commit -m "Bump 3.11/3.12/3.13 to latest patches; update script invocation"
Task 8: Add Python 3.14 workflow
Files:
-
Create:
.github/workflows/build_python_3.14.yml -
Step 1: Create the workflow file
Create .github/workflows/build_python_3.14.yml by copying .github/workflows/build_python_3.13.yml and changing:
name: Build Python 3.13→name: Build Python 3.14PYTHON_VERSION: "3.13.13"→PYTHON_VERSION: "3.14.5"PYTHON_MAJOR_VERSION: "3.13"→PYTHON_MAJOR_VERSION: "3.14"Python 3.13.13 Framework→Python 3.14.5 Framework- Upgraded Python to 3.13.13→- Upgraded Python to 3.14.5
All other contents (action versions, build script invocation from Task 7) remain identical.
- Step 2: Verify YAML parses
Run: python3 -c "import yaml; yaml.safe_load(open('.github/workflows/build_python_3.14.yml'))"
Expected: no output (no errors).
- Step 3: Commit
git add .github/workflows/build_python_3.14.yml
git commit -m "Add Python 3.14.5 build workflow"
Task 9: Mark 3.9 and 3.10 workflows as final releases
Files:
-
Modify:
.github/workflows/build_python_3.9.yml -
Modify:
.github/workflows/build_python_3.10.yml -
Step 1: Update script invocation in 3.9 workflow
In .github/workflows/build_python_3.9.yml, replace the Run build package script step's run: line with the same long-flag invocation as Task 7 Step 4 (so the final release works against the new script).
- Step 2: Add final-release notice to 3.9 release body
In .github/workflows/build_python_3.9.yml, find the Create Release step's body: block. Insert a new section immediately after the existing ## Security Notice paragraph:
## Final Release
**This is the final release of the Python 3.9 framework.** Python 3.9 reached end-of-life on October 2025 and python.org has not published a macOS installer past 3.9.13. Future framework updates will target Python 3.11 and newer. Plan your migration.
- Step 3: Update script invocation in 3.10 workflow
In .github/workflows/build_python_3.10.yml, apply the same run: replacement as Step 1.
- Step 4: Add final-release notice to 3.10 release body
In .github/workflows/build_python_3.10.yml, insert after the existing ## Security Notice paragraph:
## Final Release
**This is the final release of the Python 3.10 framework.** Python 3.10 is in security-fixes-only status and python.org has not published a macOS installer past 3.10.11. Future framework updates will target Python 3.11 and newer. Plan your migration.
- Step 5: Commit
git add .github/workflows/build_python_3.9.yml .github/workflows/build_python_3.10.yml
git commit -m "Mark 3.9 and 3.10 as final releases; switch to new script flags"
Task 10: Python package pin sweep
Files:
-
Modify:
requirements_recommended.txt(only if a package needs a bump or per-branch hold) -
Step 1: List currently pinned packages with their versions
Run: grep -E '^[a-zA-Z]' requirements_recommended.txt
Expected: 37 lines of name==version.
- Step 2: For each pinned package, check PyPI for a newer release
Run for each package (using pyobjc as the example; substitute each name):
pip index versions pyobjc --python-version 3.14 2>&1 | head -5
This requires pip 23.3+; if not available, use pip install --dry-run pyobjc==99.99 2>&1 | grep "from versions" instead.
Expected: a sorted list of available versions. Note the newest.
- Step 3: Verify arm64 wheel availability for each candidate bump
For each package where a newer version exists, check that arm64 macOS wheels are published for every supported Python branch (3.9, 3.10, 3.11, 3.12, 3.13, 3.14). Visit https://pypi.org/project/<name>/#files in a browser or run:
curl -s "https://pypi.org/pypi/<name>/<new-version>/json" \
| python3 -c "import json,sys; data=json.load(sys.stdin); files=data['urls']; [print(f['filename']) for f in files if 'macosx' in f['filename'] and 'arm64' in f['filename']]"
A package qualifies for an unconditional bump only if arm64 macOS wheels exist for all supported Python versions. If 3.9 / 3.10 lack a wheel for the new version, hold those at the older pin via Pip's per-version syntax — example: pyobjc==11.1; python_version >= "3.11" plus pyobjc==10.5.1; python_version < "3.11".
- Step 4: Update
requirements_recommended.txtwith the bumps
For each package that has a newer version with full arm64 wheel coverage, update the pin. For packages with partial coverage, use the marker syntax from Step 3. Leave packages without newer versions unchanged.
Document each change with a single-line trailing comment if it is a holdback (pyobjc==10.5.1; python_version < "3.11" # last version with 3.9/3.10 arm64 wheels).
- Step 5: Re-run local validation for 3.9, 3.10, 3.11, 3.12, 3.13, 3.14
For each version, run:
./build_python_framework_pkgs.zsh --python-version <version>
Then install and smoke-test as in Task 5 Step 2. Each must produce a working framework. If any version fails on a freshly bumped package, revert that pin or hold it for the affected version.
The patch versions to validate: 3.9.13, 3.10.11, 3.11.9, 3.12.10, 3.13.13, 3.14.5.
- Step 6: Commit
git add requirements_recommended.txt
git commit -m "Bump Python package pins; add per-branch holdbacks where needed"
Task 11: Add Dependabot config
Files:
-
Create:
.github/dependabot.yml -
Step 1: Create the config
Create .github/dependabot.yml with:
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- Step 2: Verify YAML parses
Run: python3 -c "import yaml; yaml.safe_load(open('.github/dependabot.yml'))"
Expected: no output.
- Step 3: Commit
git add .github/dependabot.yml
git commit -m "Enable Dependabot for GitHub Actions"
Task 12: Cut releases for all supported branches
Files: none (manual CI dispatch)
This is the final operational step. Each release is kicked off manually via workflow_dispatch so we can stage them and verify outputs.
- Step 1: Trigger 3.14 release first
Run: gh workflow run build_python_3.14.yml --ref <branch-with-this-plan-merged>
Expected: a workflow run starts. Watch via gh run watch or the GitHub UI.
- Step 2: Verify 3.14 release artifact
When the run finishes:
gh release view v3.14.5.<NEWSUBBUILD>
Expected: the release exists, has a signed .pkg asset, and the release body matches the new template.
- Step 3: Trigger remaining releases in order
Repeat Steps 1–2 for: build_python_3.13.yml, build_python_3.12.yml, build_python_3.11.yml, build_python_3.10.yml, build_python_3.9.yml.
The 3.9 and 3.10 releases must include the Final Release notice in their body (added in Task 9).
- Step 4: No commit
Nothing to commit — these are CI-side actions only. Phase 3 work (archiving the 3.9 / 3.10 workflows, consolidating the rest) starts after this plan is fully complete.
Self-Review Notes
- Phase 1 spec coverage: Tasks 1–6 cover the script refactor, requirements, deletions, README, and local validation.
- Phase 2 spec coverage: Tasks 7–12 cover patch bumps, 3.14 addition, final-release notes, pin sweep, Dependabot, and release execution.
- Phase 3 explicitly excluded: workflow consolidation, runner migration, action bumps, release-trigger change — Task 7 Step 4 includes a deliberate stopgap (long-flag invocation inside the still-duplicated workflows) so CI keeps working in the in-between state.
- No placeholders: every code block is concrete; every
Expected:describes verifiable output. - Type / name consistency: long-flag names (
--python-version,--installer-id,--application-id,--notary-password,--xcode-path) are identical across Tasks 1, 7, and 9.
Post-Implementation Notes (2026-05-11)
Tasks 1–11 are complete on branch claude. Task 12 is pending the branch push + merge.
Deltas from the original plan text above
-
Task 1 — Script content has three additional fixes layered onto the version this plan documents:
PYTHON_BASEURLkeeps three%sslots (not the two-slot literalmacos11version). Commite611fb1.prepare_build_dirs()explicitlychmod 777sFRAMEWORKDIRaftermkdir. Commit3057706.- The
--no-unsignflag was removed from themake_relocatable_python_framework.pyinvocation; it was disabling relocatable-python's ad-hoc re-sign step that satisfies Apple Silicon Gatekeeper. Commit69af8f1. build_pkg()nowmvs the produced.pkgtooutputs/directly so signed-but-unnotarized builds survivecleanup(). Commit29b3a55.
See the design spec's "Validation Findings" section for the full reasoning.
-
Task 7 Step 4 — Workflow
run:block now matches the spec exactly; the in-between-window stopgap (long-flag invocation inside the still-duplicated workflows) is in place across all six workflow files. Commitsc10dc22,590f478,1764c84. -
Task 9 — Release notes also include a corrected "Recommended flavor" description, since the original
… everything from minimal …line referenced a flavor that no longer exists. The fix went into the same commit as the final-release notice. -
Task 10 — Pin sweep was pulled forward and merged with the initial 3.13.13 / 3.14.5 validation (commit
1216394). The cross-version follow-up (commitf9c0853) added one holdback:pyobjc==11.1; python_version < "3.10", sincepyobjc 12.1declaresrequires_python >= 3.10andpyobjc-core 12.1lacks a cp39 wheel. Every other native-code package (cffi,charset-normalizer,PyYAML,tomli,xattr) ships universal2 wheels for cp39 through cp314 at latest. -
Task 12 — Still pending. Branch is local-only; releases will be dispatched after push + merge.
Open follow-ups
RP_SHAis held at8ee72fe(latest available). When upstream addresses gregneagle/relocatable-python#32, revisit whether further bumps are possible.- Phase 3 (CI/CD overhaul) is its own design exercise.