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
2 changes: 2 additions & 0 deletions .github/workflows/build_python_3.11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ jobs:
echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV

- name: Generate changelog
if: github.event_name == 'workflow_dispatch'
id: changelog
uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
reverse: 'true'

- name: Create Release
if: github.event_name == 'workflow_dispatch'
id: create_release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build_python_3.12.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ jobs:
echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV

- name: Generate changelog
if: github.event_name == 'workflow_dispatch'
id: changelog
uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
reverse: 'true'

- name: Create Release
if: github.event_name == 'workflow_dispatch'
id: create_release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/build_python_3.13.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ env:
DEV_INSTALLER_ID: "Developer ID Installer: Mac Admins Open Source (T4SK8ZXCXG)"
DEV_APPLICATION_ID: "Developer ID Application: Mac Admins Open Source (T4SK8ZXCXG)"
NOTARY_APP_PASSWORD: ${{ secrets.NOTARY_APP_PASSWORD_MAOS }}
PYTHON_VERSION: "3.13.13"
PYTHON_VERSION: "3.13.17"
PYTHON_MAJOR_VERSION: "3.13"

on:
Expand Down Expand Up @@ -51,13 +51,15 @@ jobs:
echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV

- name: Generate changelog
if: github.event_name == 'workflow_dispatch'
id: changelog
uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
reverse: 'true'

- name: Create Release
if: github.event_name == 'workflow_dispatch'
id: create_release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
Expand All @@ -71,7 +73,7 @@ jobs:
Python ${{env.PYTHON_VERSION}} Framework

## Changes
- Upgraded Python to 3.13.13
- Upgraded Python to 3.13.17
**Note: Some of these updates may have breaking changes. Always test your code before deploying to production!**

Please see the `requirements_recommended.txt` for the current libraries being used.
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/build_python_3.14.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ env:
DEV_INSTALLER_ID: "Developer ID Installer: Mac Admins Open Source (T4SK8ZXCXG)"
DEV_APPLICATION_ID: "Developer ID Application: Mac Admins Open Source (T4SK8ZXCXG)"
NOTARY_APP_PASSWORD: ${{ secrets.NOTARY_APP_PASSWORD_MAOS }}
PYTHON_VERSION: "3.14.5"
PYTHON_VERSION: "3.14.9"
PYTHON_MAJOR_VERSION: "3.14"

on:
Expand Down Expand Up @@ -51,13 +51,15 @@ jobs:
echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV

- name: Generate changelog
if: github.event_name == 'workflow_dispatch'
id: changelog
uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
reverse: 'true'

- name: Create Release
if: github.event_name == 'workflow_dispatch'
id: create_release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
Expand All @@ -71,7 +73,7 @@ jobs:
Python ${{env.PYTHON_VERSION}} Framework

## Changes
- Upgraded Python to 3.14.5
- Upgraded Python to 3.14.9
**Note: Some of these updates may have breaking changes. Always test your code before deploying to production!**

Please see the `requirements_recommended.txt` for the current libraries being used.
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,29 @@ Not supported.
Build an unsigned framework on Apple Silicon with:

```
./build_python_framework_pkgs.zsh --python-version 3.13.13
./build_python_framework_pkgs.zsh --python-version 3.13.17
```

Pass `--installer-id`, `--application-id`, and `--notary-password` to produce a signed and notarized `.pkg`.

The build bootstraps pip 26.2.2 from a SHA-256-verified wheel before accessing
PyPI, updates the pip bundled with `ensurepip`, and installs certifi 2026.10.19
and virtualenv 21.14.13. These updates address the
[eMudhra/emSign certificate security incident](https://discuss.python.org/t/109576)
for the framework and newly created Python 3.11–3.14 virtual environments.
Python 3.13 and 3.14 builds use the updated 3.13.17 and 3.14.9 installers;
3.11 and 3.12 retain their last upstream macOS installers with patched `ensurepip`.

Before packaging, the build verifies package dependencies, CA stores, offline
`venv` and `virtualenv` creation, and HTTPS access through the standard library
and Requests. Pull requests build and notarize packages; publishing a release
requires a manual workflow run.

Existing virtual environments have their own copies of pip and certifi and must
be updated separately using the advisory's instructions. Python 3.9 is outside
this framework's supported versions; virtualenv's compatibility seed for that
version does not include the fixed pip.

# 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.

Expand Down
39 changes: 37 additions & 2 deletions build_python_framework_pkgs.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ set -eu
# --- Pinned upstream commits ---
RP_SHA="8ee72fe3a5dbef733365370ebf44f25022b895ef" # gregneagle/relocatable-python
MP_SHA="bbd07730d1b93ed3828246575ef5676bba74b5d1" # munki/munki-pkg
# Python security advisory: https://discuss.python.org/t/109576
PIP_VERSION="26.2.2"
PIP_SHA256="fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
PIP_URL="https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-${PIP_VERSION}-py3-none-any.whl"

# --- Paths and constants ---
TYPE="recommended"
Expand Down Expand Up @@ -131,10 +135,35 @@ build_framework() {
--baseurl "${PYTHON_BASEURL}" \
--python-version "${PYTHON_VERSION}" \
--os-version 11 \
--upgrade-pip \
--pip-requirements "${TOOLSDIR}/requirements_${TYPE}.txt" \
--without-pip \
--destination "${FRAMEWORKDIR}"

# The upstream helper installs wheel online with the installer-bundled pip
# before upgrading pip. Bootstrap a verified, fixed wheel offline instead.
local python_bin="${FRAMEWORKDIR}/Python.framework/Versions/${PYTHON_BIN_VERSION}/bin/python${PYTHON_BIN_VERSION}"
local pip_dir pip_wheel
pip_dir="$(/usr/bin/mktemp -d /tmp/macadmins-python-pip.XXXXXX)"
pip_wheel="${pip_dir}/pip-${PIP_VERSION}-py3-none-any.whl"
/usr/bin/curl -fL "$PIP_URL" -o "$pip_wheel"
echo "${PIP_SHA256} ${pip_wheel}" | /usr/bin/shasum -a 256 -c -
"$python_bin" -I "${TOOLSDIR}/update_ensurepip.py" "$pip_wheel"
"$python_bin" -I -m ensurepip --upgrade --default-pip
"$python_bin" -I -m pip --isolated --disable-pip-version-check install \
--no-cache-dir wheel -r "${TOOLSDIR}/requirements_${TYPE}.txt"
/bin/rm -rf "$pip_dir"

# Installing extras above creates new console scripts after the helper's
# original relocation pass. Make their shebangs relocatable before moving.
/usr/bin/python3 -I - "$rp_extract" "${FRAMEWORKDIR}/Python.framework" "$PYTHON_BIN_VERSION" <<'PY'
import sys

sys.path.insert(0, sys.argv[1])
from locallibs.fix import fix_script_shebangs

if not fix_script_shebangs(sys.argv[2], sys.argv[3]):
raise SystemExit("Failed to relocate installed console scripts")
PY

/bin/mv "${FRAMEWORKDIR}/Python.framework" \
"$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework"
}
Expand Down Expand Up @@ -202,6 +231,7 @@ codesign_framework() {
/usr/bin/codesign -s "$identity" --deep "${cs_args[@]}" "$versioned/Resources/Python.app"
/usr/bin/codesign -s "$identity" "${cs_args[@]}" "$versioned/Python"

/usr/bin/codesign --verify --deep --strict --verbose=2 "$framework_root"
/usr/sbin/spctl -a -vvvv "$versioned/Python" || true
}

Expand Down Expand Up @@ -284,6 +314,11 @@ download_tool munki-pkg "$MP_SHA" \
"$MP_ZIP" "$MP_BINDIR"
build_framework
install_sitecustomize
# Runtime checks can create bytecode caches in the framework. Finish all such
# writes before signing so they cannot invalidate its sealed resources.
/usr/bin/env -u SSL_CERT_FILE -u SSL_CERT_DIR \
"$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework/Versions/${PYTHON_BIN_VERSION}/bin/python${PYTHON_BIN_VERSION}" \
-I "${TOOLSDIR}/verify_framework.py"
codesign_framework
build_pkg
notarize_and_staple
Expand Down
10 changes: 6 additions & 4 deletions requirements_recommended.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ asn1crypto==1.5.1
aspy.yaml==1.3.0
attrs==26.1.0
black==26.3.1
certifi==2026.4.22
certifi==2026.10.19
cffi==2.0.0
cfgv==3.5.0
charset-normalizer==3.4.7
click==8.3.3
distlib==0.4.0
distlib==0.4.4
docklib==2.0.0
entrypoints==0.4
filelock==3.29.0
Expand All @@ -19,19 +19,21 @@ isort==8.0.1
mccabe==0.7.0
mypy-extensions==1.1.0
nodeenv==1.10.0
packaging==26.2
packaging==26.3
pathspec==1.1.1
platformdirs==4.9.6
pip==26.2.2
pre-commit==4.6.0
pycodestyle==2.14.0
pycparser==3.0
pyflakes==3.4.0
python-discovery==1.6.4
pyobjc==12.1
PyYAML==6.0.3
requests==2.34.0
six==1.17.0
tokenize-rt==6.2.0
tomli==2.4.1
urllib3==2.7.0
virtualenv==21.3.2
virtualenv==21.14.13
xattr==1.3.0
31 changes: 31 additions & 0 deletions update_ensurepip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Replace ensurepip's bundled pip so offline venv creation uses fixed CAs."""

import ensurepip
import re
import shutil
import sys
from pathlib import Path


def main():
wheel = Path(sys.argv[1])
version = wheel.name.removeprefix("pip-").removesuffix("-py3-none-any.whl")
source = Path(ensurepip.__file__)
updated, count = re.subn(
r'(?m)^_PIP_VERSION = "[^"]+"$',
f'_PIP_VERSION = "{version}"',
source.read_text(),
)
if count != 1:
raise RuntimeError(f"Unrecognized ensurepip version declaration in {source}")
bundled = source.parent / "_bundled"
shutil.copyfile(wheel, bundled / wheel.name)
source.write_text(updated)
for old_wheel in bundled.glob("pip-*.whl"):
if old_wheel.name != wheel.name:
old_wheel.unlink()
print(f"ensurepip now bundles pip {version}")


if __name__ == "__main__":
main()
91 changes: 91 additions & 0 deletions verify_framework.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Smoke-test the built framework, including offline pip bootstrap paths."""

import hashlib
import importlib.metadata
import os
import ssl
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path

import certifi
import objc
import requests
import xattr
import yaml
from packaging.version import Version


# emSign ECC Root CA - G3, removed by certifi 2026.10.19. Other emSign roots
# remain trusted by that release and must not be rejected just by their names.
REVOKED_ROOT = "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b"
PIP_CHECK = """
import hashlib
import ssl
import pip
from pip._vendor import certifi
if tuple(map(int, pip.__version__.split('.'))) < (26, 2, 2):
raise RuntimeError('Vulnerable pip: ' + pip.__version__)
context = ssl.create_default_context(cafile=certifi.where())
if {revoked!r} in {{hashlib.sha256(c).hexdigest() for c in context.get_ca_certs(binary_form=True)}}:
raise RuntimeError('pip still trusts the compromised emSign root')
print('Verified pip', pip.__version__, certifi.where(), flush=True)
""".format(revoked=REVOKED_ROOT)


def run(*args):
subprocess.run(args, check=True)


def check_context(context):
fingerprints = {
hashlib.sha256(cert).hexdigest()
for cert in context.get_ca_certs(binary_form=True)
}
if not fingerprints or REVOKED_ROOT in fingerprints:
raise RuntimeError("Missing CA bundle or compromised emSign root still trusted")


def main():
print(f"Verifying framework: {sys.version}", flush=True)
for package, minimum in (("certifi", "2026.10.19"), ("pip", "26.2.2")):
version = importlib.metadata.version(package)
if Version(version) < Version(minimum):
raise RuntimeError(f"{package} {version} is older than {minimum}")
print(f"Verified {package} {version}", flush=True)

if os.environ.get("SSL_CERT_FILE") != certifi.where():
raise RuntimeError("sitecustomize did not select the bundled certifi CA store")
check_context(ssl.create_default_context())
check_context(ssl.create_default_context(cafile=certifi.where()))
run(sys.executable, "-I", "-c", PIP_CHECK)
run(sys.executable, "-I", "-m", "pip", "check")
# Exercise relocated console scripts as well as python -m entry points.
run(str(Path(sys.prefix) / "bin/pip"), "--version")
run(str(Path(sys.prefix) / "bin/virtualenv"), "--version")

with tempfile.TemporaryDirectory(prefix="macadmins-python-verify-") as temporary:
root = Path(temporary)
# Neither command may fetch a newer pip to hide an outdated seed wheel.
run(sys.executable, "-I", "-m", "venv", str(root / "venv"))
run(str(root / "venv/bin/python"), "-I", "-c", PIP_CHECK)
run(
sys.executable, "-I", "-m", "virtualenv",
"--no-download", "--no-periodic-update",
"--app-data", str(root / "app-data"), str(root / "virtualenv"),
)
run(str(root / "virtualenv/bin/python"), "-I", "-c", PIP_CHECK)

for url in ("https://www.python.org/", "https://pypi.org/simple/pip/"):
with urllib.request.urlopen(url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f"HTTPS check failed: {url}")
requests.get(url, timeout=30).raise_for_status()
print(f"Verified stdlib and Requests HTTPS: {url}", flush=True)
print("Framework verification passed", flush=True)


if __name__ == "__main__":
main()
Loading