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>
16 KiB
Apple Silicon Modernization — Design
Date: 2026-05-11 Author: Erik Gomez Status: Draft
Summary
Modernize the macadmins Python framework build pipeline by:
- Refactoring
build_python_framework_pkgs.zshto drop universal2 enforcement, run natively on Apple Silicon, and trim to a single build type (recommended). - Bumping all upstream dependencies —
relocatable-python,munki-pkg, Python interpreter patches, and Python package pins — and adding Python 3.14 as a supported branch.
CI/CD overhaul (Phase 3) is deferred to a follow-up spec; this work targets local-only changes plus the requirement and version updates that ride on top of them.
Background
build_python_framework_pkgs.zsh produces a relocatable Python3.framework installed under /Library/ManagedFrameworks/Python/, packaged via munki-pkg, signed, notarized, and shipped as a .pkg. It is consumed by Munki 6, Autopkg, InstallApplications, Nudge, and similar fleet tools.
Today the script:
- Requires an Intel build host. Lines 200–223 enumerate every
.dyliband.sounder the built framework and fail the build unless each contains two architectures. This forces--no-binaryoncffi,charset-normalizer,PyYAML,tomli,xattr, andblackinrequirements_recommended.txtso wheels are compiled fat instead of installed pre-built. - Supports three build types (
minimal,no_customization,recommended) but CI only buildsrecommended. - Pins
relocatable-pythontofb4dd9b…andmunki-pkgto96cffb4e…, both stale. - Hardcodes
Xcode_15.2.appandmacos-13(Intel) GitHub runners. - Carries a latent path bug in the ad-hoc codesign branch (
build_python_framework_pkgs.zsh:241— missing/between${FRAMEWORKDIR}andPython3.framework).
The team is shipping macOS deployments to Apple Silicon hardware; the universal2 contract is no longer worth the build-host constraints, the --no-binary slowdowns, or the maintenance surface.
Goals
- Build cleanly from an Apple Silicon Mac with no Intel host required.
- Use pre-built arm64 wheels from PyPI.
- Single supported build flavor (
recommended); delete the other two. - Current upstream SHAs and Python patch versions.
- Add Python 3.14 to the supported set.
- One last release of Python 3.9 and 3.10, then retire them.
- Script is readable, factored into clear functions, and easy to invoke locally without simulating a CI environment.
Non-Goals
- CI/CD changes (workflow consolidation, action bumps, runner migration, release trigger changes). Deferred to Phase 3.
- Compiling CPython from source for EOL branches. 3.9 / 3.10 final releases continue to use python.org's last published
.pkg(3.9.13/3.10.11). - Changing the install location, package identifier, or signing identity.
- Producing an explicitly arm64-thinned framework via
lipo. The python.org base remains universal2; arm64-only wheels make the added content single-arch. We do not strip x86_64 slices from upstream binaries.
Phase 1 — Script Refactor
Script signature
Current:
build_python_framework_pkgs.zsh <TYPE> <INSTALLER_ID> <APPLICATION_ID> <PYTHON_VERSION> <PYTHON_MAJOR_VERSION> <NOTARY_PASSWORD>
New (positional args dropped in favor of long flags for clarity; major version derived from full version):
build_python_framework_pkgs.zsh \
--python-version 3.13.13 \
[--installer-id "Developer ID Installer: ..."] \
[--application-id "Developer ID Application: ..."] \
[--notary-password "$NOTARY_APP_PASSWORD"] \
[--xcode-path /Applications/Xcode_16.x.app]
When --installer-id / --application-id are omitted, the script falls back to ad-hoc signing and does not produce a signed .pkg. When --notary-password is omitted, notarization is skipped. This makes local invocation ./build_python_framework_pkgs.zsh --python-version 3.13.13 and nothing more.
Behavioral changes
- Drop the universal2 validation block. Remove the
find … "2 architectures"checks for.dyliband.sofiles. - Drop
--no-binarymarkers inrequirements_recommended.txtforblack,cffi,charset-normalizer,PyYAML,tomli,xattr. - Guard CI-only steps. The
brew remove --force …and thesudo xcode-select -s "$XCODE_PATH"calls run only when$CIor$GITHUB_ACTIONSis set. Locally they're no-ops. - Parametrize Xcode path.
--xcode-path(orXCODE_PATHenv var) replaces theXcode_15.2.apphardcode. Default toxcode-select -p's active developer dir when not specified. - Collapse per-version symlink block. The five identical
if [[ "${PYTHON_MAJOR_VERSION}" == "3.x" ]]blocks at lines 144–158 become a single unconditionalln -s. - Single build type. Delete the
minimal/no_customizationbranches.TYPEbecomes an internal constant (recommended) used for path naming only. - Fix the codesign path bug at line 241.
- Bump pinned SHAs:
RP_SHA→8ee72fe3a5dbef733365370ebf44f25022b895efMP_SHA→bbd07730d1b93ed3828246575ef5676bba74b5d1
Refactor for readability
Pull discrete steps into named functions so the top-level script reads as a sequence of intents rather than a wall of inline shell:
parse_args— long-flag parsing, validation, defaults.prepare_build_dirs— framework dir setup, payload skeleton.download_tool <name> <sha> <url> <dest>— generic curl-and-unzip used for bothrelocatable-pythonandmunki-pkg.build_framework— runsmake_relocatable_python_framework.py.codesign_framework— single implementation that takes the identity (or-for ad-hoc) as an argument, replacing the duplicate signed-vs-ad-hoc branches.build_pkg— emitsbuild-info.json, runsmunkipkg.notarize_and_staple— runs only when notary credentials are present.zip_framework— emits the standalone framework zip.cleanup— removes temp dirs.
Use set -eu (zsh equivalent) at the top of the script so a step failure short-circuits the run instead of relying on per-step exit-code checks.
Files removed
requirements_minimal.txtrequirements_no_customization.txt(empty)requirement_files/requirements_minimal.txtrequirement_files/requirements_opinionated.txt(no longer referenced)build_all_python_frameworks.zsh(only purpose was to invoke all three build types)
Files updated
build_python_framework_pkgs.zsh— full refactor per above.requirements_recommended.txt— drop--no-binarymarkers.README.md— drop the "Flavors of Python", "No Customization", "Minimal" sections; drop the "build on an Intel macOS device" note; update interactive-use snippet for Apple Silicon.requirement_files/requirements_recommended.txt— remains as the human-curated source for which package families are included (no behavior change in Phase 1).
Local validation steps
After the refactor:
./build_python_framework_pkgs.zsh --python-version 3.13.13on the Apple Silicon Mac. Expect: an unsigned framework zip inoutputs/, no signed.pkg.- Manually install the framework to
/Library/ManagedFrameworks/Python/Python3.frameworkand smoke-test:managed_python3 --versionreports3.13.13.managed_python3 -c "import objc; import xattr; import requests; print('ok')"succeeds.managed_python3 -c "import platform; print(platform.machine())"printsarm64.
- Repeat for
--python-version 3.14.5.
Phase 2 — Dependency and Version Bumps
Python interpreter versions
| Branch | Old | New | Notes |
|---|---|---|---|
| 3.9 | 3.9.13 | 3.9.13 | Final release. No upstream .pkg past this. |
| 3.10 | 3.10.11 | 3.10.11 | Final release. No upstream .pkg past this. |
| 3.11 | 3.11.7 | 3.11.9 | |
| 3.12 | 3.12.1 | 3.12.10 | |
| 3.13 | 3.13.5 | 3.13.13 | |
| 3.14 | — | 3.14.5 | New supported branch. |
Python package pin sweep
For each pinned package in requirements_recommended.txt:
- Check if a newer release exists on PyPI.
- Verify an
arm64macOS wheel exists for every supported Python branch (3.9–3.14). If a version lacks a wheel for 3.9 / 3.10 (likely for newer pyobjc, cffi, etc.), pin a per-branch override or hold the package at the last version that supports all branches. Document the holdback inline. - The packages most likely to need attention:
pyobjc(currently 11.1; check arm64 wheel coverage for 3.14),cffi,xattr,cryptographyif pulled transitively.
3.9 / 3.10 final-release release notes
Each gets a one-time release, kicked off manually via workflow_dispatch (no CI restructuring needed for this in Phase 2), with:
- Updated package pins (from the sweep above).
- Release-notes call-out: "This is the final release of the Python <X.Y> framework. Future updates will target 3.11 and newer. Plan your migration."
The workflow files themselves (build_python_3.9.yml, build_python_3.10.yml) stay in place during Phase 2 so the final builds can run. They are moved to .github/workflows/archived/ as part of Phase 3.
3.14 enablement
.github/workflows/build_python_3.14.yml is added as a near-copy of build_python_3.13.yml, parametrized for 3.14.5. The file lives alongside the existing per-version workflows; consolidation happens in Phase 3.
Dependency-update tooling
- Dependabot for GitHub Actions: enable in
.github/dependabot.yml. Low noise; catches staleapple-actions/import-codesign-certs,actions/checkout, etc. without us thinking about it. - Pip pins: keep manual. We want intentional bumps with smoke testing, not auto-merged churn.
Deferred to Phase 3
Captured here for context — not in scope for this spec:
- Consolidate the five per-version workflows into one reusable workflow + thin callers.
- Migrate runner from
macos-13(Intel) →macos-14(Apple Silicon). - Bump action versions:
actions/checkout@v3 → v5,softprops/action-gh-release@v0.1.15 → v2,actions/upload-artifact@v4.6.2 → latest v4,apple-actions/import-codesign-certs → latest,metcalfc/changelog-generator → latest(or replace with agh apishell step). - Switch release trigger from
pull_request(currently cuts a prerelease per PR) topushon a release tag plusworkflow_dispatch. PR runs build artifacts but don't publish releases. - Archive
build_python_3.9.ymlandbuild_python_3.10.yml.
Risks and Open Questions
- arm64 wheel coverage for 3.9 / 3.10. Older Pythons may not have arm64 wheels for the newest pin of every package. Mitigation: per-branch pin overrides, or hold the package at a known-good version. The pin sweep in Phase 2 will surface this.
- First arm64 build may expose latent assumptions in
relocatable-python. The pinned-SHA bump pulls in a year+ of upstream changes; we may need to file/patch downstream issues. Mitigation: local validation gate before merging. - 3.9 / 3.10 final builds depend on the new script working with old Python branches. The bumped
relocatable-pythonshould still accept the older--python-versionvalues, but it's a small contract worth verifying as part of local validation.
Acceptance Criteria
Phase 1 is done when:
./build_python_framework_pkgs.zsh --python-version 3.13.13succeeds end-to-end on an Apple Silicon Mac with nosudoother than the existingmkdir/chownsteps and produces an installable framework zip.- The resulting
managed_python3runsimport objc; import xattr; import requestson Apple Silicon. git grep -- '--no-binary'returns no matches inrequirements_recommended.txt.minimal/no_customizationare gone from the repo and README.
Phase 2 is done when:
- All non-EOL workflows pin the patch versions in the table above.
build_python_3.14.ymlexists and runs green end-to-end.- 3.9.13 and 3.10.11 each have one final release published with "final release" notes.
.github/dependabot.ymlis enabled for GitHub Actions.
Validation Findings (post-implementation, 2026-05-11)
The Phase 1 + Phase 2 implementation surfaced four bugs and one design refinement that the earlier sections don't reflect. All are fixed in code; this section is the authoritative record of why each change was made.
1. PYTHON_BASEURL needs three %s slots, not two
The URL template must be https://www.python.org/ftp/python/%s/python-%s-macos%s.pkg. relocatable-python's locallibs/get.py passes (version, version, os-version) into base_url % (...). An earlier refactor that hardcoded macos11 left only two slots and raised TypeError: not all arguments converted during string formatting at framework-download time. Fixed in e611fb1.
2. mkdir -m 777 -p does not chmod existing directories
prepare_build_dirs() must mkdir -p and then chmod 777 explicitly. The -m 777 flag only applies to newly created directories; an existing /Library/ManagedFrameworks/Python/ (from a prior install) keeps its stricter perms, causing relocatable-python to fail with Permission denied when it ditto-extracts the python.org pkg into the framework. Fixed in 3057706.
3. --no-unsign is incompatible with Apple Silicon Gatekeeper
The original Phase 1 design kept --no-unsign in the make_relocatable_python_framework.py invocation to "preserve the python.org signature". In practice, install_name_tool invalidates that signature whether the flag is set or not; the flag's only behavioral effect is to disable relocatable-python's own fix_broken_signatures step (locallibs/fix.py), which ad-hoc re-signs the modified binaries before ensurepip runs.
Without that re-sign, Apple Silicon's Gatekeeper SIGKILLs the Python binary when ensurepip tries to execute it. This is the upstream issue gregneagle/relocatable-python#32. Solution: drop --no-unsign. The intermediate ad-hoc signature is overwritten by our codesign_framework() step at the end, so the final artifact's signature comes from our Developer ID. Fixed in 69af8f1.
This problem only manifests on Apple Silicon hosts; prior Intel CI runs masked it because Intel Gatekeeper is more lenient about invalid signatures. Switching the build host architecture is part of what this whole modernization exists to enable.
4. Signed .pkg must move to outputs/ from build_pkg(), not notarize_and_staple()
The pre-refactor script moved the produced .pkg to outputs/ unconditionally after munkipkg succeeded. The Phase 1 refactor consolidated that mv into notarize_and_staple(), which early-returns when no notary password is provided — so a signed-but-unnotarized build had its .pkg deleted by cleanup() before it ever landed in outputs/. The move now happens at the end of build_pkg(); notarize_and_staple() operates on the .pkg in its final location. Fixed in 29b3a55.
5. pyobjc holdback for Python 3.9
The Phase 2 pin sweep aspirationally bumped every package to PyPI latest. That works for 3.10 → 3.14 universally, but pyobjc 12.1 declares requires_python >= 3.10 and pyobjc-core 12.1 lacks a cp39 wheel — the Python 3.9 final-release build would fail at pip-install time. Resolution in requirements_recommended.txt:
pyobjc==12.1; python_version >= "3.10"
pyobjc==11.1; python_version < "3.10"
This is the only holdback discovered. All other native-code packages (cffi, charset-normalizer, PyYAML, tomli, xattr) ship universal2 wheels for cp39 through cp314 at their latest versions; pure-Python packages install on any Python 3.x. Fixed in f9c0853.
Acceptance criteria — actual results
- ✅
./build_python_framework_pkgs.zsh --python-version 3.13.13succeeds end-to-end on Apple Silicon, produces an installable framework zip, and the resultingmanaged_python3runsimport objc, xattr, requests, yamlon arm64. - ✅ Same for 3.14.5.
- ⏳ Cross-version validation for 3.9 / 3.10 / 3.11 / 3.12 — pending local runs.
- ✅ Five workflow files updated to long-flag invocation;
build_python_3.14.ymladded. - ✅ Dependabot enabled for GitHub Actions.
- ⏳ Final releases for 3.9 / 3.10 — pending the manual
workflow_dispatchafter merge.
Items rolled over to follow-up work
- Bump
RP_SHApast8ee72feonce upstream addresses the Apple Silicon ensurepip flow (tracked: gregneagle/relocatable-python#32). - Phase 3 (CI/CD overhaul) — workflow consolidation, runner migration to
macos-14, action version bumps, release trigger change, archival ofbuild_python_3.9.ymlandbuild_python_3.10.yml. Out of scope for this spec.